8.2 运算符
C#运算符非常类似于C++和Java运算符,但有一些区别。
C#支持表8-1中的运算符。
表8-1
注意:有4个运算符(sizeof、*、->和&)只能用于不安全的代码(这些代码忽略了C#的类型安全性检查),这些不安全的代码见第5章的讨论。
使用C#运算符的一个最大缺点是,与C风格的语言一样,对于赋值(=)和比较(==)运算,C#使用不同的运算符。例如,下述语句表示“使x等于3”:
x = 3;
如果要比较x和另一个值,就需要使用两个等号(==):
if (x == 3) { }
幸运的是,C#非常严格的类型安全规则防止出现常见的C错误,也就是在逻辑语句中使用赋值运算符代替比较运算符。在C#中,下述语句会产生一个编译器错误:
if (x = 3) { }
习惯使用与字符(&)来连接字符串的Visual Basic程序员必须改变这个习惯。在C#中,使用加号(+)连接字符串,而“&”符号表示两个不同整数值的按位AND运算。“|”符号则在两个整数之间执行按位OR运算。Visual Basic程序员可能还没有使用过取模(%)运算符,它返回除运算的余数,例如,如果x等于7,则x % 5会返回2。
在C#中很少会用到指针,因此也很少用到间接寻址运算符(->)。使用它们的唯一场合是在不安全的代码块中,因为只有在此C#才允许使用指针。指针和不安全的代码见第5章。
8.2.1 运算符的简化操作
表8-2列出了C#中的全部简化赋值运算符。
表8-2
为什么用两个例子来分别说明“++”递增和“- -”递减运算符?把运算符放在表达式的前面称为前置,把运算符放在表达式的后面称为后置。要点是注意它们的行为方式有所不同。
递增或递减运算符可以作用于整个表达式,也可以作用于表达式的内部。当x++和++x单独占一行时,它们的作用是相同的,对应于语句x = x + 1。但当它们用于较长的表达式内部时,把运算符放在前面(++x)会在计算表达式之前递增x;换言之,递增了x后,在表达式中使用新值进行计算。而把运算符放在后面(x++)会在计算表达式之后递增x——使用x的原始值计算表达式。下面的例子使用“++”增量运算符说明了它们的区别:
int x = 5; if (++x == 6) // true - x is incremented to 6 before the evaluation { WriteLine("This will execute"); } if (x++ == 7) // false - x is incremented to 7 after the evaluation { WriteLine("This won't"); }
判断第一个if条件得到true,因为在计算表达式之前,x值从5递增为6。然而,第二条if语句中的条件为false,因为在计算整个表达式(x == 6)后,x值才递增为7。
前置运算符--x和后置运算符x--与此类似,但它们是递减,而不是递增。
其他简化运算符,如+=和-=,需要两个操作数,通过对第一个操作数执行算术、逻辑运算,从而改变该操作数的值。例如,下面两行代码是等价的:
x += 5; x = x + 5;
下面介绍在C#代码中频繁使用的基本运算符和类型强制转换运算符。
1.条件运算符
条件运算符(? :)也称为三元运算符,是if...else结构的简化形式。其名称的出处是它带有3个操作数。它首先判断一个条件,如果条件为真,就返回一个值;如果条件为假,则返回另一个值。其语法如下:
condition ? true_value: false_value
其中condition是要判断的布尔表达式,true_value是condition为真时返回的值,false_value是condition为假时返回的值。
恰当地使用三元运算符,可以使程序非常简洁。它特别适合于给调用的函数提供两个参数中的一个。使用它可以把布尔值快速转换为字符串值true或false。它也很适合于显示正确的单数形式或复数形式,例如:
int x = 1; string s = x + " "; s += (x == 1 ? "man": "men"); WriteLine(s);
如果x等于1,这段代码就显示1 man;如果x等于其他数,就显示其正确的复数形式。但要注意,如果结果需要本地化为不同的语言,就必须编写更复杂的例程,以考虑到不同语言的不同语法规则。
2. checked和unchecked运算符
考虑下面的代码:
byte b = byte.MaxValue; b++; WriteLine(b);
byte数据类型只能包含0~255的数,给byte.MaxValue分配一个字节,得到255。对于255,字节中所有可用的8个位都得到设置:11111111。所以递增这个值会导致溢出,得到0。
CLR如何处理这个溢出取决于许多因素,包括编译器选项;所以只要有未预料到的溢出风险,就需要用某种方式确保得到我们希望的结果。
为此,C#提供了checked和unchecked运算符。如果把一个代码块标记为checked, CLR就会执行溢出检查,如果发生溢出,就抛出OverflowException异常。下面修改上述代码,使之包含checked运算符:
byte b = 255; checked { b++; } WriteLine(b);
运行这段代码,就会得到一条错误信息:
System.OverflowException: Arithmetic operation resulted in an overflow.
注意:用/checked编译器选项进行编译,就可以检查程序中所有未标记代码的溢出。
如果要禁止溢出检查,则可以把代码标记为unchecked:
byte b = 255; unchecked { b++; } WriteLine(b);
在本例中不会抛出异常,但会丢失数据——因为byte数据类型不能包含256,溢出的位会被丢弃,所以b变量得到的值是0。
注意,unchecked是默认行为。只有在需要把几行未检查的代码放在一个显式标记为checked的大代码块中时,才需要显式地使用unchecked关键字。
注意:默认编译设置是/unchecked,因为执行检查会影响性能。使用/checked时,每一个算术运算的结果都需要验证其值是否越界。算术运算也可以用于使用i++的for循环中。为了避免这种性能影响,最好一直使用默认的/ unchecked编译器设置,在需要时使用checked运算符。
3. is运算符
is运算符可以检查对象是否与特定的类型兼容。短语“兼容”表示对象或者是该类型,或者派生自该类型。例如,要检查变量是否与object类型兼容,可以使用下面的代码:
int i = 10; if (i is object) { WriteLine("i is an object"); }
int和所有C#数据类型一样,也从object继承而来;在本例中,表达式i is object将为true,并显示相应的消息。
4. as运算符
as运算符用于执行引用类型的显式类型转换。如果要转换的类型与指定的类型兼容,转换就会成功进行;如果类型不兼容,as运算符就会返回null值。如下面的代码所示,如果object引用实际上不引用string实例,把object引用转换为string就会返回null:
object o1 = "Some String"; object o2 = 5; string s1 = o1 as string; // s1 = "Some String" string s2 = o2 as string; // s2 = null
as运算符允许在一步中进行安全的类型转换,不需要先使用is运算符测试类型,再执行转换。
注意:is和as运算符也用于继承,参见第4章。
5. sizeof运算符
使用sizeof运算符可以确定栈中值类型需要的长度(单位是字节):
WriteLine(sizeof(int));
其结果是显示数字4,因为int有4个字节长。
如果对复杂类型(而非基本类型)使用sizeof运算符,就需要把代码放在unsafe块中,如下所示:
unsafe { WriteLine(sizeof(Customer)); }
第5章将详细论述不安全的代码。
6. typeof运算符
typeof运算符返回一个表示特定类型的System.Type对象。例如,typeof(string)返回表示System.String类型的Type对象。在使用反射技术动态地查找对象的相关信息时,这个运算符很有用。第16章将介绍反射。
7. nameof运算符
nameof是新的C# 6运算符。该运算符接受一个符号、属性或方法,并返回其名称。
这个运算符如何使用?一个例子是需要一个变量的名称时,如检查参数是否为null:
public void Method(object o)
{
if (o == null) throw new ArgumentNullException(nameof(o));
当然,这类似于传递一个字符串来抛出异常,而不是使用nameof运算符。然而,如果名称拼错,传递字符串并不会显示一个编译器错误。另外,改变参数的名称时,就很容易忘记更改传递到ArgumentNullException构造函数的字符串。
if (o == null) throw new ArgumentNullException("o");
对变量的名称使用nameof运算符只是一个用例。还可以使用它来得到属性的名称,例如,在属性set访问器中触发改变事件(使用INotifyPropertyChanged接口),并传递属性的名称。
public string FirstName
{
get { return _firstName; }
set
{
_firstName = value;
OnPropertyChanged(nameof(FirstName));
}
}
nameof运算符也可以用来得到方法的名称。如果方法是重载的,它同样适用,因为所有的重载版本都得到相同的值:方法的名称。
public void Method()
{
Log($"{nameof(Method)} called");
8. index运算符
前面的第7章中使用了索引运算符(括号)访问数组。这里传递数值2,使用索引运算符访问数组arr1的第三个元素:
int[] arr1 = {1, 2, 3, 4};
int x = arr1[2]; // x == 3
类似于访问数组元素,索引运算符用集合类实现(参见第11章)。
索引运算符不需要把整数放在括号内,并且可以用任何类型定义。下面的代码片段创建了一个泛型字典,其键是一个字符串,值是一个整数。在字典中,键可以与索引器一起使用。在下面的示例中,字符串first传递给索引运算符,以设置字典里的这个元素,然后把相同的字符串传递给索引器来检索此元素:
var dict = new Dictionary<string, int>(); dict["first"] = 1; int x = dict["first"];
注意:本章后面的“实现自定义索引运算符”一节将介绍如何在自己的类中创建索引运算符。
9.可空类型和运算符
值类型和引用类型的一个重要区别是,引用类型可以为空。值类型(如int)不能为空。把C#类型映射到数据库类型时,这是一个特殊的问题。数据库中的数值可以为空。在早期的C#版本中,一个解决方案是使用引用类型来映射可空的数据库数值。然而,这种方法会影响性能,因为垃圾收集器需要处理引用类型。现在可以使用可空的int来替代正常的int。其开销只是使用一个额外的布尔值来检查或设置空值。可空类型仍然是值类型。
在下面的代码片段中,变量i1是一个int,并给它分配1。i2是一个可空的int,给它分配i1。可空性使用?与类型来定义。给int ?分配整数值的方式类似于i1的分配。变量i3表明,也可以给可空类型分配null。
int i1 = 1; int? i2 = 2; int? i3 = null;
每个结构都可以定义为可空类型,如下面的long?和DateTime?所示:
long? l1 = null; DateTime? d1 = null;
如果在程序中使用可空类型,就必须考虑null值在与各种运算符一起使用时的影响。通常可空类型与一元或二元运算符一起使用时,如果其中一个操作数或两个操作数都是null,其结果就是null。例如:
int? a = null; int? b = a + 4; // b = null int? c = a * 5; // c = null
但是在比较可空类型时,只要有一个操作数是null,比较的结果就是false。即不能因为一个条件是false,就认为该条件的对立面是true,这种情况在使用非可空类型的程序中很常见。例如,在下面的例子中,如果a是空,则无论b的值是+5还是-5,总是会调用else子句:
int? a = null; int? b = -5; if (a >= b) { WriteLine("a >= b"); } else { WriteLine("a < b"); }
注意:null值的可能性表示,不能随意合并表达式中的可空类型和非可空类型,详见8.3.1小节的内容。
注意:使用C#关键字?和类型声明时,例如int ?,编译器会解析它,以使用泛型类型Nullable<int>。C#编译器把速记符号转换为泛型类型,减少输入量。
10.空合并运算符
空合并运算符(? ? )提供了一种快捷方式,可以在处理可空类型和引用类型时表示null值的可能性。这个运算符放在两个操作数之间,第一个操作数必须是一个可空类型或引用类型;第二个操作数必须与第一个操作数的类型相同,或者可以隐式地转换为第一个操作数的类型。空合并运算符的计算如下:● 如果第一个操作数不是null,整个表达式就等于第一个操作数的值。
● 如果第一个操作数是null,整个表达式就等于第二个操作数的值。
例如:
int? a = null; int b; b = a ? ? 10; // b has the value 10 a = 3; b = a ? ? 10; // b has the value 3
如果第二个操作数不能隐式地转换为第一个操作数的类型,就生成一个编译时错误。
空合并运算符不仅对可空类型很重要,对引用类型也很重要。在下面的代码片段中,属性Val只有在不为空时才返回_val变量的值。如果它为空,就创建MyClass的一个新实例,分配给val变量,最后从属性中返回。只有在变量_val为空时,才执行get访问器中表达式的第二部分。
private MyClass _val; public MyClass Val { get { return _val ? ? (_val = new MyClass()); }
11.空值传播运算符
C# 6的一个杰出新功能是空值传播运算符。生产环境中的大量代码行都会验证空值条件。访问作为方法参数传递的成员变量之前,需要检查它,以确定该变量的值是否为null,否则会抛出一个NullReferenceException。.NET设计准则指定,代码不应该抛出这些类型的异常,应该检查空值条件。然而,很容易忘记这样的检查。下面的这个代码片段验证传递的参数p是否非空。如果它为空,方法就只是返回,而不会继续执行:
public void ShowPerson(Person p) { if (p == null) return; string firstName = p.FirstName; //... }
使用空值传播运算符来访问FirstName属性(p? .FirstName),当p为空时,就只返回null,而不继续执行表达式的右侧。
public void ShowPerson(Person p) { string firstName = p? .FirstName; //... }
使用空值传播运算符访问int类型的属性时,不能把结果直接分配给int类型,因为结果可以为空。解决这个问题的一种选择是把结果分配给可空的int:
int? age = p? .Age;
当然,要解决这个问题,也可以使用空合并运算符,定义另一个结果(例如0),以防止左边的结果为空:
int age = p? .Age ? ? 0;
也可以结合多个空值传播运算符。下面访问Person对象的Address属性,这个属性又定义了City属性。Person对象需要进行null检查,如果它不为空,Address属性的结果也不为空:
Person p = GetPerson(); string city = null; if (p ! = null && p.Address ! = null) { city = p.Address.City; }
使用空值传播运算符时,代码会更简单:
string city = p? .Address? .City;
还可以把空值传播运算符用于数组。在下面的代码片段中,使用索引运算符访问值为null的数组变量元素时,会抛出NullReferenceException:
int[] arr = null; int x1 = arr[0];
当然,可以进行传统的null检查,以避免这个异常条件。更简单的版本是使用?[0]访问数组中的第一个元素。如果结果是null,空合并运算符就返回x1变量的值:
int x1 = arr? [0] ? ? 0;
8.2.2 运算符的优先级和关联性
表8-3显示了C#运算符的优先级,其中顶部的运算符有最高的优先级(即在包含多个运算符的表达式中,最先计算该运算符)。
表8-3
除了运算符优先级之外,对于二元运算符,需要注意运算符是从左向右还是从右到左计算。除了少数运算符之外,所有的二元运算符都是左关联的。例如:
x + y + z
就等于:
(x + y) + z
需要先注意运算符的优先级,再考虑其关联性。在以下表达式中,先计算y和z相乘,再把计算的结果分配给x,因为乘法的优先级高于加法:
x + y * z
关联性的重要例外是赋值运算符,它们是右关联。下面的表达式从右到左计算:
x = y = z
因为存在右关联性,所有变量x、y、z的值都是3,且该运算符是从右到左计算的。如果这个运算符是从左到右计算,就不会是这种情况:
int z = 3; int y = 2; int x = 1; x = y = z;
一个重要的、可能误导的右关联运算符是条件运算符。表达式
a ? b: c ? d: e
等于:
a = b: (c ? d: e)
这是因为该运算符是右关联的。
注意:在复杂的表达式中,应避免利用运算符优先级来生成正确的结果。使用圆括号指定运算符的执行顺序,可以使代码更整洁,避免出现潜在的冲突。