第六章 运算符和类型强制转换

运算符

​ C# 运算符非常类似于 C++, 但也有一些区别. 下表由上到下按照 C# 运算符优先级排列. | 运算符 | 类别或名称 | | ———————————————————— | ————————- | | x.y, f(x), a[i], x?.y, x?[y\], x++, x–, x!, new, typeof, checked, unchecked, default, nameof, delegate, sizeof, stackalloc, x->y | 主要 | | +x, -x, x, ~x, ++x, –x, ^x, (T)x, await, &x, *x, true 和 false | 一元 | | x..y | 范围 | | switch, with | switchwith 表达式 | | x * y, x / y, x % y | 乘法 | | x + y, x – y | 加法 | | x « y, x » y | Shift | | x < y, x > y, x <= y, x >= y, is, as | 关系和类型测试 | | x == y, x != y | 相等 | | x & y | 按位 AND | | x ^ y | 按位 XOR | | x | y | 按位 OR | | x && y | 短路条件“与” | | x || y | 短路条件“或” | | x ?? y | Null 合并运算符 | | c ? t : f | 条件运算符 | | x = y, x += y, x -= y, x *= y, x /= y, x %= y, x &= y, x |= y, x ^= y, x «= y, x »= y, x ??= y, => | 赋值和 lambda 声明 |

  • 位运算可以用于 bool 类型, 此时不同于短路条件运算符&&, ||, 位运算符用于 bool 类型时没有短路现象

checkedunchecked运算符

​ 考虑下面代码:

byte b = byte.MaxValue;
b++;
Console.WriteLine(b);

byte 类型范围是0~255, 上述程序造成数值上溢, 输出0. 如果将一个代码块标记为checked, CLR 就会执行溢出检查, 如果发生溢出就抛出OverflowException异常. 在checked代码块中将一块代码标记为unchecked可以禁止溢出检查. 默认不检查溢出, 因为这会影响性能.

isas运算符

is运算符检查对象是否与特定类型兼容, 若兼容则表达式为true, 否则为false. 可以在类型后同时声明临时变量用来接收转换后的引用, 若兼容则用转换后的引用为该临时变量赋值. ​ as运算符用于执行引用类型的显式类型转换, 若要转换的引用与要转换的类型兼容则转换成功, 表达式返回转换后的引用; 否则表达式返回null.

  • “兼容”表示对象或者是该类型, 或者派生自该类型
public Func1(object o) 
{
    if (o is Person p) { /*do something*/ }
    
    string s = p as string;
    //do something
}

sizeof运算符

sizeof运算符可以确定栈中值类型需要的长度, 单位是字节. 如果结构体只包含值类型, 也可以使用sizeof运算符获取大小.

​ 如果对基本类型之外的类型使用sizeof运算符, 需要把代码放在unsafe代码块中.

只有在项目文件中指定AllowUnsafeBlocksTrue才允许使用 unsafe 代码块.

public struct Point
{
    int X { get; }
    int Y { get; }
}
//...
unsafe 
{
    Console.WriteLine(sizeof(Point));
}

typeof运算符

typeof运算符返回一个表示特定类型的System.Type对象. 这个运算符在使用反射技术动态查找对象相关信息的时候很有用. 第16章将介绍反射.

nameof运算符

nameof运算符参数接受一个一个符号, 属性或方法, 并返回其名称. 这有助于把重命名变量失误造成的错误提前到编译期, 而不是使其发生运行时错误, 甚至因为只有逻辑错误而导致的匪夷所思的结果.

switch (e.PropertyName)
{
    case nameof(SomeProperty):
    { break; }

    // opposed to
    case "SomeOtherProperty":
    { break; }
}

在第一种情况下, 如果不同时更改属性定义和nameof(SomeProperty)表达式, 则重命名SomeProperty将导致编译错误。在第二种情况下, 重命名Somewhere Property或更改“Somewhere Property”字符串将导致静默中断的运行时行为, 在构建时没有错误或警告。

[]index 运算符

​ 数组使用[]运算符访问元素. ​ index 运算符不一定接受整数. 可以使用 index 运算符创建字典, 其键为字符串, 值为整数.

var dict = new Dictionary<string, int>();
dict["first"] = 1;
int x= dict["first"];

可空类型与运算符

  1. 可空类型

​ 可空类型使用type?定义, 如int?. 每个值类型结构都可以定义为可空类型. ​ 通常可空类型与二元运算符一起使用时, 如果其中一个操作数为 null, 其结果就是 null.

int? a = null;
int? b = a + 4;	//b=null
int? c = a * 9;	//c=null

​ 在可空类型参与比较时, 只要有一个操作数是 null, 结果就是 false. 这意味着不能因为一个条件是 false 就认为其对立面是 true. 例如, 在下面的例子中, 如果a为空, 则无论b为 +5 还是 -5 都会调用else子句. 显然这在逻辑上是不合理的.

int? a = null;
if (a >= b)
    Console.WriteLine("a >= b");
else
    Console.WriteLine("a < b");

在使用type?声明时, 编译器会将其解析为Nullable<type>. C# 有很多语法糖来通过速记符号减少输入量.

  1. 空合并运算符??

​ 二元运算符空合并运算符??类似于三元条件判断运算符?:, 只不过判断条件不是布尔值, 而是左操作数是否为 null. 若??的左操作数非空, 则表达式值为左操作数的值, 否则为右操作数值. ??左操作数不仅可以是可空结构, 也可以是引用类型与不可空类型.

//用法
int? a = null;
int b;
b = a ?? 10;//b=10
a = 3;
b = a ?? 9;	//b=3

//另一种用法
private MyClass _val;
public MyClass Val
{
    get => _val ?? (_val = new MyClass());
}

??运算符要求第二个操作数能够隐式转换为第一个操作数的类型, 否则会生成一个编译错误.

  1. 空值条件运算符?

​ 空值条件运算符?为一元左结合性运算符, 如果操作数非空, 则返回操作数本身并继续执行表达式右面的运算, 不影响其右侧运算符; 如果操作数为空, 则返回 null 而不继续执行表达式右侧运算符的计算.

public void DoSomethingWithAPerson(Person p)
{
    string firstname = p?.FirstName;
    int? age = p?.Age;
    string city = p?.HomeAddress?.City;
}

还可以把空值条件运算符用于数组.

int x = arr?[0] ?? 0;

二进制运算符

​ C# 支持的二进制运算符有 按位非~, 按位与&, 按位或|, 按位异或^, 按位左移<<, 按位右移>>, 无符号右移>>>. 其中按位非~为一元运算符, 其他均为二元运算符. 其优先级为: 按位非 > 移位 > 按位与 > 按位异或 > 按位或.

移位

​ 移位运算符仅针对 int, uint, longulong 类型定义, 因此运算的结果始终包含至少 32 位. 如果左侧操作数是其他整数类型(sbyte, byte, short, ushortchar), 则其值将转换为 int 类型. ​ 对无符号数的移位没有特殊规则, 下面主要关注移位运算符如何处理有符号数的符号位.

  1. 按位左移

​ 按位左移对符号位与数值位一视同仁: 符号位会与数值位一同左移, 右侧多出的位补 0, 且左侧超过数值类型位大小的溢出部分会被截断. 即无视位的作用, 对所有位执行左移. 对于正数来说, 左移一位等价于数值 *2, 但对负数左移时应特殊注意.

  1. 按位右移

​ 按位右移会丢弃右侧溢出的位, 左侧多出的位会与符号位相同, 即正数补 0, 负数补 1.

  1. 无符号右移

​ 无符号右移会将有符号数的符号位一视同仁, 会将符号位与数值位一同右移, 并总是在左侧补 0. 即无视位的作用, 对所有位执行右移.

int a = int.MinValue + 1;
Console.WriteLine(a.ToString() + " " + Convert.ToString(a, toBase: 2));
Console.WriteLine((a << 2).ToString() + " " + Convert.ToString(a << 2, toBase: 2));
Console.WriteLine((a >> 2).ToString() + " " + Convert.ToString(a >> 2, toBase: 2));

Console.WriteLine();

int b = 1;
Console.WriteLine(b.ToString() + " " + Convert.ToString(b, toBase: 2));
Console.WriteLine((b << 1).ToString() + " " + Convert.ToString(b << 1, toBase: 2));
Console.WriteLine((b >> 1).ToString() + " " + Convert.ToString(b >> 1, toBase: 2));

Console.WriteLine();

int c = int.MinValue + 0b1000_0000_0000_0000_0000;
Console.WriteLine(c.ToString() + " " + Convert.ToString(c, toBase: 2));
Console.WriteLine((c << 2).ToString() + " " + Convert.ToString(c << 2, toBase: 2));
Console.WriteLine((c >> 2).ToString() + " " + Convert.ToString(c >> 2, toBase: 2));

输出:

-2147483647	 10000000000000000000000000000001
4 									   100
-536870912 	 11100000000000000000000000000000

1 1
2 10
0 0

-2146959360 	10000000000010000000000000000000
2097152 				 1000000000000000000000
-536739840 		11100000000000100000000000000000

类型安全与类型转换

隐式类型转换

​ 只要保证值不会发生任何变化, 隐式(自动)转换就可以进行. 需要注意的是 double 类型不能隐式转换到任意基本类型, 包括 decimal 类型(二者大小与存储方式均不同). 事实上, float 类型也不能隐式转换到 decimal, 但可以隐式转换到 double. ​ 所有整数都可以隐式转换到任意浮点数, 因为这样做不会丢失信息, 只可能丢失精度(编译器认为这是可接受的). 所有整数都可以转换到BigInteger类型.

  • BigInteger位于System.Numeric, 是包含任意大小整数的结构体. 可以从较小的类型初始化它, 传递一个数字数组创建一个大数字, 或解析包含数字的字符串. 这种类型也实现了数学计算的方法.

​ char 类型可以隐式转换到整数或浮点数.

(显式)强制类型转换

​ 使用 C 语言风格的强制类型转换, 即将目标类型放在圆括号中.

​ 将整数转换为字符型, 可空类型转换为不可空类型等操作需要进行强制类型转换; 将浮点数强制转换为整数会丢弃浮点数的小数点部分. ​ bool 类型无法与其他类型进行强制类型转换, 如需进行该类转换可以使用静态方法Convert.ToBoolean()Boolean.TryParse()(转换到 bool )与Convert.ToInt32(转换到 int ). ​ 装箱操作通过隐式类型转换进行, 但拆箱操作需要使用强制类型转换显式进行.

​ 如果要分析一个字符串, 可以使用所有预定义类型都支持的Parse()方法: ​ int i = int.Parse("100"); 如果转换无法进行(如试图将”Hello”转换为整数), Parse()方法就会抛出一个异常. 如果不想处理异常, 可以使用TryParse()方法, 此方法返回一个布尔值标志转换是否成功.

比较对象的相等性

System.Object定义了 3 个不同方法来比较对象的相等性: 静态方法ReferenceEquals(), 静态方法Equals(), 虚拟实例方法Equals(). 再加上比较运算符==, 实际上有 4 种比较相等性的方法.

比较引用类型的相等性

  1. ReferenceEquals(object?, object?)方法

ReferenceEquals()方法是静态方法, 因此不能被重写. 它比较两个引用是否指向相同的内存地址, 如果相同则返回 true, 否则返回 false. 该方法认为 null 等于 null.

  1. Equals(object?)虚方法

​ 虚拟的Equals()方法在object中的默认实现也可以比较引用类型的引用, 但因为是虚方法, 所以可以在自己的类中重写它, 从而实现按值比较对象. 重写该方法时不能抛出异常, 否则会使一些在内部调用这个方法的 .NET 基类出问题. ​ 重写该方法时应首先检查作为参数的对象是否为 null 与是否与需要比较的类兼容, 因为方法的参数为object?类型.

特别是如果需要类的实例用作字典中的键, 就需要重写这个方法, 以比较相关值. 否则, 根据重写Object.GetHashCode()的方式, 包含对象的字典要么不工作, 要么效率很低. 在重写该方法时不应抛出异常, 否则字典类就会出问题.

  1. Equals(object?, object?)静态方法

Equals()的静态方法与其实例化版本作用相同, 但它可以处理实例方法无法处理的对象中有一个是 null 的情况. 这个方法可以抛出异常, 因此可以在一个对象是 null 时抛出异常, 提供额外的保护. ​ 静态重载版本首先要检查传递给它的引用是否为 null. 如果二者都是 null 则返回true, 如果只有一个是 null 则返回 false. 如果两个引用都指向了某个对象(即二者均不为 null), 它就调用Equals()的虚实例版本. 这表示重写Equals()的虚实例版本也相当于重写了其静态版本.

  1. ==比较运算符

​ 最好将==比较运算符视为严格的值比较与严格的引用比较的中间选项. ==对引用类型的默认行为是比较引用相等, 但==可以被重载. 如果一些类被看作值, 将==重载为比较值后其含义就会比较直观, 这是可以接受的. 一个明显例子是System.String类, 其==运算符用来比较字符串内容, 而不是它们的引用.

比较值类型的相等性

​ 值类型的相等性较为简单. ​ 对于值类型的结构来说, System.ValueType重载了实例方法Equals(), 用来逐字段比较两个结构是否每字段都相等. 若两个结构对象每个字段都相等则返回 true, 否则返回 false. ​ 自己定义的结构没有默认的==运算符, 想要使该运算符有效必须为结构重载==运算符. ​ 对值类型调用ReferenceEquals()方法总是返回 false. 这是因为值类型在通过该方法比较引用时会先装箱, 而这会导致同一对象也生成两个不同的引用对象, 即对每个参数进行装箱时, 每个参数都会被单独装箱, 这意味着总是会得到不同的引用. 对值类型调用ReferenceEquals()几乎没有任何意义, 因此不应该这样做.

​ 尽管System.ValueType重载的实例方法Equals()足以应付大部分情况的使用, 但仍可针对自己的结构再次重写它, 以提高性能. 特别的, 如果结构中包含引用类型, 且包含的引用类型应当对值进行相等性比较而不是引用, 则此时应当重写Equals()方法以便为这些字段提供合适的语义, 因为默认的Equals()函数默认只比叫引用类型的地址.

运算符重载

​ 运算符重载的语法大体上与 C++ 相同, 但需要注意的是 C# 要求所有运算符重载都声明为 publicstatic, 这表示它涉及的类或结构相关, 而不是与某个特定实例相关. 因此运算符重载方法中不能访问非静态成员, 也不能访问 this 标识符, 因为参数提供了运算符执行运算所需要的所有数据.

​ 下面以一个三维向量结构Vector(成员有X, Y, Z三个只读属性, 构造函数, 复制构造函数与ToString方法)的运算符重载为例来作说明.

​ 重载+运算符, 对于二元运算符来说, 第一个参数为左操作数, 第二个为右操作数:

public static Vector operator +(Vector left, Vector right) =>
    new Vector(left.X + right.X, left.Y + right.Y, left.Z + right.Z);

​ 重载*运算符作为向量数量积运算, 对于操作数类型不同但符合交换律的运算, 可以写好一个运算顺序方法后在交换参数顺序后的方法中调用写好的运算方法:

public static Vector operator *(Vector left, double right) =>
    new Vector(left.X * right, left.Y * right, left.Z * right);
public static Vector operator *(double left, Vector right) =>
    right * left;

​ 对于运算赋值运算符, 如+=, 只要重载了+运算符, C# 就会自动使用重载后的+运算符来实现+=运算符, 而不需要(事实上也不能)单独重载相加赋值运算符. 其他所有运算赋值运算符都遵循此规则.

​ C# 允许成对重载truefalse运算符, 由此提供哪些值代表true, 哪些代表false. 这是因为根据所使用的技术或框架, 哪些值代表truefalse是不同的.

​ C# 不允许重载赋值运算符=.

比较运算符的重载

​ C# 中有 3 对比较运算符:

  • ==!=
  • ><
  • >=<=

C# 要求必须成对地重载比较运算符, 否则会产生编译错误. 另外, 比较运算符必须返回布尔类型的值, 这也是它们和算术运算符的根本区别. 一般情况下, 可以在重载==运算符后直接使用==来重载!=运算符:

public static bool operator ==(Vector left, Vector right)
{
    if (object.ReferenceEquals(left, right)) return true;
    return (left.X == right.X && left.Y == right.Y && left.Z == right.Z);
}
public static bool operator !=(Vector left, Vector right) => 
    !(left == right);

如果已经重载了==使其按值比较相等性, 则可以使用重载的==来实现重载Equals()方法. Equals()GetHashCode()方法总是应当在重载==后进行重写, 否则会产生编译错误.

public override bool Equals(object obj)
{
    if (obj == null) return false;
    return this == (Vector)obj;
}
public override int GetHashCode() =>
    X.GetHashCode() ^ Y.GetHashCode() ^ Z.GetHashCode();

​ 需要特别注意的是, 不应该使用重载的Equals()方法实现重载==, 即不应在==的重载中调用同类型的Equals()方法. 这是因为在判断objA == objB时, 如果objA为 null, .NET 运行库就会因试图计算null.Equals(objB)而产生异常. 在==中调用Equals()方法比较安全.

​ 对于值类型, 还应一同实现接口IEquatable<T>. 该接口实现一个强类型化Equals()方法, 即public bool Equals(T? other);. 有了其他方法的重载, 就很容易实现该方法. 如果实现了IEquatable<T>接口, 通常也应实现object.Equals(object?)以确保在所有上下文中表现一致.

自定义的索引运算符[]

​ 自定义索引器不能使用运算符重载语法实现, 而是有单独的语法. ​ 下面以类PersonCollection类为例子来介绍相关内容. 该类有一个Person数组私有字段和一个构造函数:

public class PersonCollection
{
    private Person[] _people;
    public PersonCollection(params Person[] people) =>
        _people = people.ToArray();
}

​ 现在为它创建一个索引器. 索引器看起来非常像属性, 因为索引器也包含 get 和 set 访问器. 指定索引器使用this关键字, []方括号中包含索引使用的类型与参数.

public Person this[int index]
{
    get => _people[index];
    set => _people[index] = value;
}

不仅能定义int类型作为索引类型, 任何类型都是有效的. 下面的代码使用索引器返回有指定生日的每个人:

public IEnumerable<Person> this[DateTime birthday]
{
    get => _people.Where(p => p.Birthday == birthday);
}

其中的Where()方法根据 Lambda 表达式进行过滤, 位于命名空间System.Linq中. 也可以写成下面的等价形式:

public IEnumerable<Person> this[DateTime birthday] =>
    _people.Where(p => p.Birthday == birthday);

自定义的类型强制转换

​ C# 允许为自定义类型添加类型转换运算符作为类型转换方法. 类型转换运算符使用operator关键字声明, 与运算符重载一样需要声明为 public 和 static. 不同与其他运算符声明的是, 类型转换运算符必须标记为隐式(implicit)或显式(explicit), 以说明希望如何使用它. ​ 定义类型转换时应遵循与预定义类型转换相同的指导原则: 如果类型转换总是安全的, 则可以定义为隐式类型转换, 否则应该定义为显式类型转换.

​ 下面的代码为Currency类添加隐式转换到float类型的类型强制转换:

public static implicit operator float(Currency value) =>
    value.Dollars + (value.Cents/100.0f);

然后定义floatCurrency的类型转换. 因为float可能为负, 且float存储数值的数量级远大于Currencyuint类型成员DollarsCents的上限, 所以这个转换可能失败, 应当定义为显式类型转换.

public static explicit operator Currency(float value)
{
    if (value < 0) 
        throw new ArgumentOutOfRangeException("Cuurrency cannot be negative!");
    uint dollars = (uint)value;
    ushort cents = Convert.ToUInt16((value-dollars)*100);
    //float精度问题可能使小数部分产生误差, 应使用Convert转换类型而不是直接丢弃小数部分
    return new Currency(dollars, cents);
}

System.Convert的方法执行它们自己的溢出检查, 会有一定性能损失, 所以应当只在有必要使用的场合使用.

类之间的类型转换

​ 定义不同类之间或结构之间的类型转换有一定限制:

  • 如果一个类直接或间接派生自另一个类, 则不能定义这两个类之间的类型转换, 因为这些类型间的类型转换已经存在
  • 类型强制转换必须在源数据类型或目标数据类型之间定义

多重类型强制转换

​ 如果进行要求的类型转换时没有可用的直接强制转换方式, C# 编译器就会寻找一种简介转换方式完成该过程, 即利用多种隐式转换与显式指定的类型转换完成源类型到目标类型的转换. ​ 例如, Currency类可以隐式转换到float类型. 如果有以下代码:

var bal = new Currency(10, 50);
long amount = (long)balance;

如果没有定义Currency类到long的类型转换, 那么编译器在执行(long)balance时会先将balance隐式转换为float类型, 再将得到的float类型显式转换为long类型. 但这一转换在该情境下显然是不合理的, 因为这会丢失Cents部分的数据, 所以应该为Currency类定义到long的类型转换, 或改变已有的类型转换使得编译器无论选择何种路径进行转换都是有意义的. 编译器总是按照逻辑与严格的规则来工作, 但结果可能并不是我们所期望的. 如果存在任何疑问, 最好显式地指定使用哪种强制转换.