C#高级编程(第10版) C# 6 & .NET Core 1.0 (.NET开发经典名著)
上QQ阅读APP看书,第一时间看更新

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)

这是因为该运算符是右关联的。

注意:在复杂的表达式中,应避免利用运算符优先级来生成正确的结果。使用圆括号指定运算符的执行顺序,可以使代码更整洁,避免出现潜在的冲突。