导航
第六章 Operators and Casts
6.1 概述
前面的章节已经覆盖了大部分必要的编写实用C#程序的知识点。本章接着讨论语言要点(essential language elements)并且举例说明C#易于扩展的各种强大之处。
本章主要覆盖了运算符使用的相关信息,包括那些从C# 6.0开始新增的运算符,比如空条件运算符或者nameof运算符,也包括C# 7.0对运算符扩展的部分,就像使用is运算符进行模式匹配。本章最后你将会了解到运算符是如何进行重载的,向你演示了使用运算符时如何实现自定义的功能。
6.2 运算符
C#的运算符和C++还有Java的运算符很类似,尽管如此,还是有些细微的差别。
C#支持下面列表列举的运算符:
类型 | 运算符 |
---|---|
算数运算符 | + - * / % |
逻辑运算符 | & | ^ && || ! |
字符串连接 | + |
递增/递减 | ++ -- |
位运算符 | ~ << >> |
比较 | == != < > <= >= |
赋值 | = += -= *= /= %= &= |= ^= ≪= ≫= |
成员访问(对于class和struct而言) | . |
索引(对数组和索引器而言) | [] |
强制转换 | () |
三元条件运算符 | ? : |
委托新增和移除 | + - |
对象创建 | new |
类型信息 | sizeof is typeof as |
溢出异常控制 | checked unchecked |
间接取值与地址 | [] |
命名空间别名 | :: |
空值联结运算符 | ?? |
空值条件运算符 | ?. ?[] |
标识符名称 | nameof() |
注意:有4个特殊的运算符,sizeof,*(按地址取变量),->,&(取变量地址)只允许在不安全代码(unsafe code)里使用,第17章我们将对此进行介绍。
使用C#运算符的时候,要留心一个大陷阱,就是与其他C风格的语言一样,C#为赋值(=)和比较(==)使用了不同的运算符。就像下面这个例子,意味着"将3赋值给x":
x = 3;
如果现在你想将x跟某个值进行比较,你需要使用双等号==
,下面表达式将判断x是否等于4:
x == 4;
更幸运的是,C#是一门严格类型安全的语言,因此它可以避免一种很常见的C语言错误,就是当你在需要书写一个比较语句的地方,你漏写了一个=
号,这个语句就变成了赋值语句了。在其他语言里可能没事,但C#会给你提示一个编译错误,如下所示:
if (x = 3) // 无法将int隐式转换成bool
{
//...
}
VB程序员通常更习惯使用&
符来拼接两个字符串,在C#里需要重新适应,因为C#使用的是+
来拼接字符串,&
符在C#里是逻辑与运算。管道符号|
在C#里则是逻辑或运算。VB里没有%
运算符,然而在C#里,%
运算符用来返回除法运算的余数,因此当x=7时,x%5的值将是2。
在C#里你将不怎么会用到指针,因此相关的间接运算符用的也少。更明确一点地讲,你只能在不安全的代码块里使用它们,只有在这些代码块里才允许使用指针。指针和非安全代码将在第17章进行介绍。
6.2.1 运算符的简写
下面的表格列举了所有C#赋值运算符的缩写方式:
简写 | 等价于 |
---|---|
x++,++x | x = x + 1 |
x--,--x | x = x - 1 |
x += y | x = x + y |
x -= y | x = x - y |
x *= y | x = x * y |
x /= y | x = x / y |
x %= y | x = x % y |
x >>= y | x = x >> y |
x <<= y | x = x << y |
x &= y | x = x & y |
x |= y | x = x | y |
你可能会疑惑为何对于++
和--
有两种写法,将运算符写在变量前面的我们称之为前缀式(prefix),写在变量后面的我们称之为后缀式(postfix)。其实写在变量前面和后面是有区别的,以++
为例,虽然前缀式和后缀式同样相当于执行了x=x+1,但在复合表达式的时候,前缀式会先将x自加,然后将运算后的新值再进行复合表达式的进一步运算。相反的,后缀实则先将x的初值提供给复合表达式进行运算,最后才将x自加。下面的例子++
运算符来演示前缀式和后缀式之间的不同表现:
int x = 5;
if (++x == 6) // true – x先自加,再进行判断,此时x为6,因此为true。
{
Console.WriteLine("This will execute");
}
if (x++ == 7) // false – x先判断是否等于7,此时x为6,不等于7,所以为false,最后x自加,变成7
{
Console.WriteLine("This won't");
}
而--
的前缀式和后缀式的表现和++
是类似的,只是它执行的是对操作数自减1而已。
另外一种运算符的缩写方式,如+=
和-=
,需要两个操作数,将两个操作数进行算数运算或者逻辑运算后的结果,赋值给第一个操作数。例如下面两个语句是等价的:
x += 5;
x = x + 5;
接下来的各小节将会带你了解C#代码里常用的一些基础操作符和强制转换运算符。
6.2.2 条件表达式运算符 ( ? : )
条件表达式运算符 ( ? : ),也被成为三元运算符,是if...else代码段的速写方式。从名字就可以看出,它需要3个操作数。它允许你计算一个条件,条件为true值时返回一个值,而条件结果为false时返回另外一个值,语法如下所示:
condition ? true_value : false_value
这里,condition是一个用来计算出Boolean值的表达式,当表达式结果为true时,就会返回true_value的值,反之则返回false_value的值。
当你细致地使用条件表达式的时候,它会让你的代码看起来更加的简介。它经常会用来根据某个功能提供一组参数中的某个值。你可以用它快速地将一个布尔值转换成相应的字符串。用来表示单词的单数或者复数形式也很方便,如下所示:
int x = 1;
string s = x + " ";
s += (x == 1 ? "man": "men");
Console.WriteLine(s);
这段代码当x为1时显示的是"1 man"而当它为其他数字时则显示的是复数的"men"。请留意,当你需要根据地区显示不同语言的时候,你需要编写更加复杂的判断语句来适配不同语言的语言规范。
6.2.3 checked和unchecked
考虑以下的代码:
byte b = byte.MaxValue;
b++;
Console.WriteLine(b);
byte数据类型只能存储0-255之间的数值,将byte.MaxValue赋值给变量b,此时b的值为255,而相应的8位二进制数字将被设置为1111 1111。此时对b进行自增只会导致数据溢出,b的值就变成了0。
CLR如何处理这个问题取决于不同的情况,包括编译选项的设置。因此,为了处理那些无时无刻都存在的无意的数据溢出问题,你需要能确保得到的值确实是你想要的的某种解决方案。
为了实现这一点,C#提供了checked和unchecked运算符。如果你将某段代码标记成checked,CLR将会强制进行数据溢出检查,一旦发生溢出,就会直接抛出一个OverflowException。正如下面的例子所示:
byte b = 255;
checked
{
b++; // System.OverflowException: Arithmetic operation resulted in an overflow.
}
Console.WriteLine(b);
你可以在VS项目属性设置是否要进行算数运算的溢出检查,如下图所示:
你也可以直接在csproj
项目文件里添加一项<CheckForOverflowUnderflow>为true来强制检查所有未编辑的代码段:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> <!--为所有代码进行检查-->
</PropertyGroup>
如果你想忽略溢出检查,你可以将指定代码段标记为unchecked:
byte b = 255;
unchecked
{
b++;
}
Console.WriteLine(b);
在这种情况下,将不会抛出任何异常,但你将会丢失数据,因为byte类型无法保存一个256的值,溢出的二进制位直接被抛弃,剩下的二进制位都是0,所以你最终得到了一个值为0的变量b。
注意unchecked是默认操作。只有当你在一个显式标记为checked的大代码段内需要忽略某些数据溢出的时候,你才需要显式指定unchecked。
注意:默认情况下,上下限溢出并不会被检查因为它对性能有影响。当你为你的项目使用默认的check操作时,每个算数运算的结果都会被确认,无论它是否会有溢出。即使是for语句里常见的i++语句也不例外。为了不过度的影响程序性能,将溢出检查设置为默认不检查是更好的方案,你只需要在需要的地方使用checked运算符即可。
6.2.4 is运算符
is
运算符允许你检查某个实例是否兼容(compatible)某个指定类型。"is compatible"的意思是一个实例是否某个类型或者由某个类型的派生类型所创建。例如,为了检查一个变量是否兼容object类型,你可以这么写:
int i = 10;
if (i is object)
{
Console.WriteLine("i is an object");
}
int类型,跟所有的C#数据类型一样,最终继承自object,因此,表达式i is object将得到一个true值,相应的信息也会在控制台上显示。
C# 7.0扩展了is操作符用于模式匹配。你可以用is来检查常量,类型和变量。一个常量检查的例子如下所示,它判断i是否是常量值42,o是否为常量值null:
int i = 42;
if (i is 42)
{
Console.WriteLine("i has the value 42");
}
object o = null;
if (o is null)
{
Console.WriteLine("o is null");
}
通过使用is运算符,判断类型是否匹配的时候,可以在类型的右侧声明一个变量。假如is运算符的表达式计算结果为true,变量将会指向类型的实例。如下面的if代码段所示,该变量可随即在有效作用域内调用:
public static void AMethodUsingPatternMatching(object o)
{
if (o is Person p)
{
Console.WriteLine($"o is a Person with firstname {p.FirstName}");
}
}
//...
AMethodUsingPatternMatching (new Person("Katharina", "Nagel"));
6.2.5 as运算符
as运算符用在引用类型上,用来显示地进行类型转换。如果某个对象可以转换成指定类型,as就会成功执行。假如类型不匹配,as运算符将会返回一个null值。
如下面代码所示,尝试将两个不同的object类型通过as运算符转换成string类型:
object o1 = "Some String";
object o2 = 5;
string s1 = o1 as string; // s1 = "Some String"
string s2 = o2 as string; // s2 = null
as操作符允许你在一步内执行安全的类型转换,而不用事先通过is运算符进行判断再进行类型转换。
注意:第4章的时候我们已经简单的介绍过is和as运算符了。而在第13章的时候,我们将会对模式匹配和is运算符做更进一步的介绍。
6.2.6 sizeof运算符
你可以通过sizeof运算符来决定一个值类型在栈里存储的内存大小(以byte为单位):
Console.WriteLine(sizeof(int));
上面这句代码将会输出4,因为int是4字节长度。
假如struct值拥有值类型变量的时候,你也可以对struct使用sizeof运算符,就像下面这个Point结构体:
public struct Point
{
public Point(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
注意:你不能将sizeof运算符用在class上。
为了在代码中使用sizeof,你需要显式声明一个unsafe代码块:
unsafe
{
Console.WriteLine(sizeof(Point));
}
注意:默认情况下是不允许书写不安全的代码的,你需要在"项目->生成"中勾选上"允许不安全代码",或者在
csproj
项目文件中添加上<AllowUnsafeBlocks>标签,设置为true。第17章将会更详细的介绍不安全代码。
6.2.7 typeof运算符
typeof运算符将会返回一个System.Type类型的实例用来代表指定类型。例如,typeof(string)返回的是一个Type对象,用来代表System.String类型。
这点在你通过反射技术从一个对象中动态查找指定信息时非常有用,第16章的时候我们会详细介绍反射技术。
6.2.8 nameof运算符
nameof运算符是从C# 6.0开始才有的新特性。这个运算符接收一个标识符,属性或者方法,返回相应的名称。
那这玩意可以用来干嘛呢?举个例子,当你需要用到一个变量名称的时候:
public void Method(object o)
{
if (o == null) throw new ArgumentNullException(nameof(o));
}
当然,你可能会觉得与其使用nameof运算符,为啥不直接写一个"o"的字符串名称呢,看上去似乎也没啥不同:
if (o == null) throw new ArgumentNullException("o");
然而,简单地使用字符串名称的时候,假如你拼错了某个单词,是不会引起任何编译错误的,尤其是用那些特别长的英文单词的时候。并且,当你修改变量名称的时候,你可能会很容易忘记修改相应的传递给ArgumentNullException构造函数的名称参数,因为编译器不会提示你。
为变量使用nameof运算符获得变量名只是其中一个用法。你也可以用它来获取属性的名称,例如,在set访问器里触发某些修改事件(使用接口INotifyPropertyChanged)并且将属性名称作为参数进行传递:
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged(nameof(FirstName));
}
}
nameof还可以用来获取方法名称,甚至可以用在重载方法上,因为所有的重载方法除了签名不同外,他们的名字都是一样的:
public void Method()
{
Log($"{nameof(Method)} called");
}
6.2.9 index运算符
你将会用到索引运算符(中括号)来访问数组,第7章的时候我们将会做更详细的介绍。在下面的例子中,序号运算符用来访问数组arr1的第三个元素值,通过传递一个数字2作为参数:
int[] arr1 = {1, 2, 3, 4};
int x = arr1[2]; // x == 3
与访问数组元素类似,index运算符也适用于集合类,集合类将在第10章进行更详细的介绍。
索引运算符在中括号中并非只能接受整数作为参数,它也可以是其他类型。下面的代码中我们创建了一个泛型字典,Key值为string类型,Value值为int类型。为了读写字典中的元素,我们可以使用索引。在下面的示例中,我们首先为索引器传递了一个字符串first,设置字典中对应的值为1,然后通过相同的索引将值提取出来赋给另外一个变量:
var dict = new Dictionary<string, int>();
dict["first"] = 1;
int x = dict["first"];
注意:在后续的"实现自定义索引运算符"小节中,你将会了解如何为你自己的类创建一个索引器。
6.2.10 可空类型与运算符
值类型和引用类型中一个很重要的区别就是引用类型可以为null值。一个值类型,如int,无法设置为null。这将会是个问题当将C#类型与数据库类型进行映射的时候。一个数据库的数值可以是null。在早期的C#版本里,一个解决方案就是使用引用类型来映射可为null的数据库数值。然而,这种方法会影响性能,因为引用类型需要GC进行处理回收。现在你可以使用可空类型int?
来代替普通的int了。它唯一的开销就是多了一个额外的布尔值,用来检查是否设置了值。可空类型仍然是值类型。
在下面的代码片段里,i1是一个int类型的变量,被赋值为1。i2则是一个可空int类型,值为2。可空类型是在常规类型后面加上?
。int?
类型可以像int类型一样赋值。i3则演示了可空变量可以直接赋值为null:
int i1 = 1;
int? i2 = 2;
int? i3 = null;
每个结构体都可以被定义为可空类型,就像long?
和DateTime?
:
long? l1 = null;
DateTime? d1 = null;
假如你在代码中使用了可空类型,你必须时刻考虑可空类型面对不同运算符时null值的影响。通常,当使用一元(unary)或者二进制运算符配合可空类型进行运算时,只要有一个操作数为null的话,结果往往也为null:
int? a = null;
int? b = a + 4; // b = null
int? c = a * 5; // c = null
当对可空类型进行比较时,假如只有一个操作数为null,则比较结果为false。这意味着某些时候你不需要考虑结果为true的可能,只需要考虑它的反面肯定是false的情况即可。这种情况经常发生在和正常类型进行比较时。举个例子,在下面的例子里,只要a为null值,则else语句总是为true的不管b就是是+5还是-5。
int? a = null;
int? b = -5;
if (a >= b) // if a or b is null, this condition is false
{
Console.WriteLine("a >= b");
}
else
{
Console.WriteLine("a < b");
}
注意:
可能为null值意味着你不能简单地在一个表达式里同时使用可空类型和非可空类型。这将在本章稍后的"类型转换"中介绍。
当然你使用在C#的类型定义后面使用关键字
?
,如int?
,编译器将这个类型处理成泛型类型Nullable<int>。编译器将简写的方式替换成泛型类型减少类型转换的开销。
6.2.11 空值合并运算符 ( ?? )
空值合并(coalescing)运算符 ( ?? )提供了一种简写的方式,来满足处理可空类型或者引用类型为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的值,当且仅当其不为空时。假如_val为空,一个新的MyClass实例将会被创建,并且赋值给_val变量,并最终返回给属性。get访问器里表达式的第二部分仅当_val值为空的时候才会被调用:
private MyClass _val;
public MyClass Val
{
get => _val ?? (_val = new MyClass());
}
6.2.12 空值条件运算符 ( ?. 和?[] )
有一个用来精简代码行数的C#特性叫做空值条件运算符。生产环境中我们往往要编写大量的代码来判断空值情况。在访问方法参数的成员变量之前,需要先检查参数是否有值,还是为null。如果你不检查,就有可能会引发NullReferenceException。.NET设计指南中要求代码必须对null的情况进行检查并且不要出现NullReferenceException这种低级异常。然而,某些检查可能会轻易的就忘记了。下面这个代码片段检查了方法接收到的参数p是否为null。假如它为null,方法则直接退出:
public void ShowPerson(Person p)
{
if (p == null) return;
string firstName = p.FirstName;
//...
}
而使用空值条件运算符来访问FirstName属性的时候(p?.FirstName),当p为null时,只有null值会返回而不会继续执行右侧的表达式:
public void ShowPerson(Person p)
{
string firstName = p?.FirstName;
//...
}
当一个int类型的属性要使用空值条件运算符的时候,它的结果不能直接赋值给一个普通的int类型因为结果可能会为空,一个解决方案是赋值给可空int类型int?
:
int? age = p?.Age;
当然你也可以使用空值合并运算符来解决这个问题,通过定义一个缺省值(例如0)来应付万一左侧的表达式为null的情况:
int age1 = p?.Age ?? 0;
多个空值条件运算符也可以被合并。这里有一个Person对象,我们要访问它的Address属性,而这个属性又包含一个City属性,我们可能会先检查Person对象是否为空,然后再检查Address属性是否为空,都不为空的情况下我们才能访问到City属性:
Person p = GetPerson();
string city = null;
if (p != null && p.HomeAddress != null)
{
city = p.HomeAddress.City;
}
而当你使用空值条件运算符的时候,你就可以简单地这么写:
string city = p?.HomeAddress?.City;
你也可以在数组里使用空值条件运算符。考虑下面这段代码,当一个数组变量为空时,尝试访问数组中的元素将会导致一个NullReferenceException:
int[] arr = null;
int x1 = arr[0]; // Object reference not set to an instance of an object.
当然你可以用原始的写法来避免这种异常情况,而我们这里要说的是一种更简单的方法,通过使用?[]
来访问数组。如下所示,假如arr数组为null,则不会接着执行[0]访问arr的第一个数组元素,直接就返回一个null值,并且这里使用了空值合并运算符??
,当左侧的表达式返回为null时,我们将右侧的0赋值给x1:
int x1 = arr?[0] ?? 0;
6.2.13 运算符的优先级和关联性
下面的表格显示了C#运算符之间的优先级关系。从上到下,优先级从高到低(意思是在符合表达式内,优先级高的运算符先进行求值运算):
分组 | 运算符 | 结合性 |
---|---|---|
基础 | . ?. () [] ?[] x++ x–– new typeof sizeof checked unchecked |
左->右 |
一元 | + - ! ~ ++x ––x (Type) |
右->左 |
乘除 | * / % | 左->右 |
加减 | + - | 左->右 |
位运算 | << >> | 左->右 |
关系 | < > <= >= is as | 左->右 |
比较 | == != | 左->右 |
按位与 | & | 左->右 |
按位或 | | | 左->右 |
逻辑与 | && | 左->右 |
逻辑或 | || | 左->右 |
空值合并 | ?? | 左->右 |
条件表达式 | ? : | 右->左 |
赋值与Lambda | = += -= *= /= %= &= |= ^= ≪= ≫= => |
右->左 |
逗号 | , | 左->右 |
除了优先级之外,对于二元运算符(binary operators)来说,你还需要注意它的求值是从左到右还是从右到左的。除了少数特殊情况外,所有的二元运算符都是从左到右的。例如,下面两个语句是等价的:
x + y + z
(x + y) + z
在考虑二元运算是如何求值之前,你需要先考虑运算符的优先级。考虑以下的表达式:
x + y * z
首先应该计算的是y和z的乘积,然后将结果再与x进行求和,因为乘法的优先级比加法高。
最典型的从右向左运算的例子就是赋值运算符。例如下面的代码,我们首先是将z的值赋值给y,然后将y的值再赋给x:
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)
注意:在复杂表达式里,要尽量避免根据运算符优先级来得到正确的运算结果。尽量使用小括号来阐明你想要的运算顺序以免不必要的困扰。
6.3 使用二进制运算符
因为电脑是根据0和1进行工作的,在学习编程的时候,先了解究竟是如何操作二进制数据的,是非常重要的。现在很多人可能并不了解这一块内容,因为它们开始编程的时候都是直接从Blocks,Scratch或者Javascript开始的。即使你曾经对于0-1操作很熟悉,本小节也能帮你重新回顾这一部分内容。
C# 7.0开始,操作二进制值开始变得更加容易,因为你可以用数字分隔符(digit seperators)和二进制字面量(binary literals)。这些特性在第2章"Core C#"里已经大概地进行了介绍。二进制运算符则是从C# 1.0开始就存在了,并且本小节将会涵盖它们的内容。
首先,让我们先创建一个简单的二进制运算的示例。方法SimpleCalculations首先定义了两个变量binary1和binary2,并且用二进制值为它们 赋值——通过字面量和数字分隔符的方式。通过使用&
运算符,这两个变量的值进行了求和运算,并将结果赋给了binaryAdd变量。然后,|
运算符用来创建binaryOr变量的值,同理^
运算符用来计算binayXOR变量,~
运算符则是对binay1进行取反操作:
static void SimpleCalculations()
{
Console.WriteLine(nameof(SimpleCalculations));
uint binary1 = 0b1111_0000_1100_0011_1110_0001_0001_1000;
uint binary2 = 0b0000_1111_1100_0011_0101_1010_1110_0111;
uint binaryAnd = binary1 & binary2;
DisplayBits("AND", binaryAnd, binary1, binary2);
uint binaryOR = binary1 | binary2;
DisplayBits("OR", binaryOR, binary1, binary2);
uint binaryXOR = binary1 ^ binary2;
DisplayBits("XOR", binaryXOR, binary1, binary2);
uint reverse1 = ~binary1;
DisplayBits("NOT", reverse1, binary1);
Console.WriteLine();
}
为了将uint和int类型的变量显示为二进制数,我们创建了扩展方法ToBinaryString。Convert.ToString方法提供了带有两个int类型参数的方法重载,其中第二个参数叫toBase,用来指定进制,如果你给这个参数传2,则显示的是二进制,同理8是八进制,10是十进制,16则是十六进制。默认情况下,一个二进制值是由0开始的,这些0会被忽略并且不被输出。PadLeft方法用来为这些二进制字符串填充相应的"0"。所需的字符串位数通过sizeof云算法进行计算并且左移了3位,前面已经介绍了sizeof运算符可以返回指定类型需要的byte位数,为了按bit进行显示,byte字节数需要乘以8,就跟数值左移3位一样的结果。另外一项扩展方法是AddSeparators,使用LINQ方法,来为字符串按每4个字符之间插入一个_
符号:
public static class BinaryExtensions
{
public static string ToBinaryString(this uint number) =>
Convert.ToString(number, toBase: 2).PadLeft(sizeof(uint) << 3, '0');
public static string ToBinaryString(this int number) =>
Convert.ToString(number, toBase: 2).PadLeft(sizeof(int) << 3, '0');
public static string AddSeparators(this string number) =>
string.Join('_',
Enumerable.Range(0, number.Length / 4)
.Select(i => number.Substring(i * 4, 4)).ToArray());
}
注意:我们将在第十二章详细介绍LINQ,"Language Integrated Query"。
而SimpleCalculations方法里调用的DisplayBits,则在内部调用了ToBinaryString和AddSeparators方法。在这里,显示了每个运算符所需的操作数,和它的运算结果:
static void DisplayBits(string title, uint result, uint left, uint? right = null)
{
Console.WriteLine(title);
Console.WriteLine(left.ToBinaryString().AddSeparators());
if (right.HasValue)
{
Console.WriteLine(right.Value.ToBinaryString().AddSeparators());
}
Console.WriteLine(result.ToBinaryString().AddSeparators());
Console.WriteLine();
}
当你执行程序的时候,你将会看到以下的结果:
- 使用二进制
&
按位与运算符时,只有当两个操作数的相同位都是1的时候,结果值上对应位才为1,否则为0:
AND
1111_0000_1100_0011_1110_0001_0001_1000 // binary1
0000_1111_1100_0011_0101_1010_1110_0111 // binary2
0000_0000_1100_0011_0100_0000_0000_0000 // binaryAdd
- 对于
|
按位或运算符时,只要其中一个操作数某一位为1,结果值相同的位就是1:
OR
1111_0000_1100_0011_1110_0001_0001_1000 // binary1
0000_1111_1100_0011_0101_1010_1110_0111 // binary2
1111_1111_1100_0011_1111_1011_1111_1111 // binaryOr
^
按位异或运算符,则是当两个操作数相同位上的数值不同时,结果为1,譬如一个操作数为1,另外一个操作数相同位上为0时,结果为1:
XOR
1111_0000_1100_0011_1110_0001_0001_1000 // binary1
0000_1111_1100_0011_0101_1010_1110_0111 // binary2
1111_1111_0000_0000_1011_1011_1111_1111 // binaryXOR
- 最后,对于
~
取反运算符,直接将操作数按位置为另外一个二进制值,譬如原来该位是1,取反则是0,原来是0,取反则是1:
NOT
1111_0000_1100_0011_1110_0001_0001_1000 // binary1
0000_1111_0011_1100_0001_1110_1110_0111 // reverse1
6.3.1 移位
就像你在上面例子中看到的那样,将数据左移三位就相当于值乘以8。每左移一位都相当于将值乘以2。这比直接调用乘法运算符要快的多——假如你需要乘以2,4,8,16,32,以此类推。
下面的代码段中我们为变量s1设置了一位二进制值,在for循环里依次左移一位,输出它的二进制,十进制和十六进制值:
static void ShiftingBits()
{
Console.WriteLine(nameof(ShiftingBits));
ushort s1 = 0b01;
for (int i = 0; i < 16; i++)
{
Console.WriteLine($"{s1.ToBinarString()} {s1} hex: {s1:X}");
s1 = (ushort)(s1 ≪ 1);
}
Console.WriteLine();
}
输出将如下所示:
0000000000000001 1 hex: 1
0000000000000010 2 hex: 2
0000000000000100 4 hex: 4
0000000000001000 8 hex: 8
0000000000010000 16 hex: 10
0000000000100000 32 hex: 20
0000000001000000 64 hex: 40
0000000010000000 128 hex: 80
0000000100000000 256 hex: 100
0000001000000000 512 hex: 200
0000010000000000 1024 hex: 400
0000100000000000 2048 hex: 800
0001000000000000 4096 hex: 1000
0010000000000000 8192 hex: 2000
0100000000000000 16384 hex: 4000
1000000000000000 32768 hex: 8000
6.3.2 有符号数和无符号数
有一个需要记住的要点就是当你使用有符号类型,如int,long,short来存储二进制数时,它的左边第一位是用来代表符号的。当你使用int时,它所能代表的最大值为2147483647——也就是31位的1(二进制)或者0x7FFF FFFF。而使用uint的话,则最大值可以是4294967295——32位的1(二进制)或者0xFFFF FFFF。使用int的时候,另外一半范围用来表示负数。
为了理解负的二进制数是如何表示的,下面的代码段里初始化了一个maxNumber变量,用来存储31位int的最大值int.MaxValue,然后,通过for循环,将这个变量自增3次,然后我们看一下结果的二进制,十进制和十六进制表示:
private static void SignedNumbers()
{
Console.WriteLine(nameof(SignedNumbers));
void DisplayNumber(string title, int x) => Console.WriteLine($"{title,-11} " + $"bin: {x.ToBinaryString().AddSeparators()}, " + $"dec: {x}, hex: {x:X}");
int maxNumber = int.MaxValue;
DisplayNumber("max int", maxNumber);
for (int i = 0; i < 3; i++)
{
maxNumber++;
DisplayNumber($"added {i + 1}", maxNumber);
}
Console.WriteLine();
}
运行程序你可以看到所有的二进制位——除了最左边的符号位——都被设置成了1来代表最大的整数值。我们还可以看到相应值的十进制和十六进制值。第一次给maxNumber增加1的时候二进制变成了第一位符号位为1,其他位都为0的情况,这串二进制在int类型里代表的是最大的负数-2147483648,这点是人为规定的。之后又执行了两次自增1,得到的数则是相应的-2147483647和-2147483646:
max int bin: 0111_1111_1111_1111_1111_1111_1111_1111, dec: 2147483647, hex: 7FFFFFFF
added 1 bin: 1000_0000_0000_0000_0000_0000_0000_0000, dec: -2147483648, hex: 80000000
added 2 bin: 1000_0000_0000_0000_0000_0000_0000_0001, dec: -2147483647, hex: 80000001
added 3 bin: 1000_0000_0000_0000_0000_0000_0000_0010, dec: -2147483646, hex: 80000002
而在下一个例子里,我们将变量zero赋值为0,并且在for循环里将它自减1三次,如下所示:
int zero = 0;
DisplayNumber("zero", zero);
for (int i = 0; i < 3; i++)
{
zero--;
DisplayNumber($"subtracted {i + 1}", zero);
}
Console.WriteLine();
从输出中你会发现,0代表着所有数据位都设置为1,而第一次对它进行减1操作的时候,所有的数据位都被置为1,用来代表有符号数里的-1:
zero bin: 0000_0000_0000_0000_0000_0000_0000_0000, dec: 0, hex: 0
subtracted 1 bin: 1111_1111_1111_1111_1111_1111_1111_1111, dec: -1, hex: FFFFFFFF
subtracted 2 bin: 1111_1111_1111_1111_1111_1111_1111_1110, dec: -2, hex: FFFFFFFE
subtracted 3 bin: 1111_1111_1111_1111_1111_1111_1111_1101, dec: -3, hex: FFFFFFFD
接下来,我们将int的最大的负数,也是int的最小值int.MinValue自增三次:
int minNumber = int.MinValue;
DisplayNumber("min int", minNumber);
for (int i = 0; i < 3; i++)
{
minNumber++;
DisplayNumber($"added {i + 1}", minNumber);
}
Console.WriteLine();
前面你已经通过最大值+1溢出之后得到了最小值,只不过这里我们用int.MinValue来代表而已,结果与前面很类似:
min int bin: 1000_0000_0000_0000_0000_0000_0000_0000, dec: -2147483648, hex: 80000000
added 1 bin: 1000_0000_0000_0000_0000_0000_0000_0001, dec: -2147483647, hex: 80000001
added 2 bin: 1000_0000_0000_0000_0000_0000_0000_0010, dec: -2147483646, hex: 80000002
added 3 bin: 1000_0000_0000_0000_0000_0000_0000_0011, dec: -2147483645, hex: 80000003
6.4 类型的安全性
第一章,".NET 应用程序与工具"里强调了中间语言(IL)是强类型安全要求的。强类型使得.NET提供的很多服务,包括安全和不同语言之间的交互成为可能。就像你从前面看到的一样,C#也是强类型的语言。换句话讲,强类型意味着数据类型之间无法简单地进行无缝转换。本小节的内容主要关注基础类型之间的转换。
注意:C#也支持不同引用类型之间的转换,并且允许你定义你自己建立的数据类型如何与其他类型进行转换。大部分情况都会在本章进行介绍。
而泛型,可以让你避免大部分情况下的类型转换,这点我们在第五章已经介绍过了,而第10章我们将进一步介绍更多细节。
6.4.1 类型转换
通常,你总是会遇到将数据类型转换成另外一种的情况,如下所示:
byte value1 = 10;
byte value2 = 23;
byte total;
total = value1 + value2;
Console.WriteLine(total);
当你试图编译这段代码时,会得到一个编译错误:
无法隐式地将类型"int"转换成"byte"。
这里的问题在于当你将两个byte类型的值进行加法计算时,结果返回的却是一个int值,而非byte。这事因为byte只能存储8bit的数据,将两个byte相加很容易结果无法在一个byte里存储。假如你需要将这个结果存储到一个byte变量上,你需要重新将计算结果转换为byte。接下来的小节将会介绍C#里的两个转换机制——隐式(implicit)和显式(explicit)。
6.4.1.1 隐式转换
类型之间的转换可以简单地自动完成只要你保证值不会被其它方式改变。这也是为何上面的例子会失败的原因,尝试将"int"转换成"byte",你将可能失去3字节(byte)的有效数据。编译器不会自动为你执行这种转换,除非你显式地指定这就是你想要的。而如果你将结果值存储在一个long类型里而非byte类型的话,则完全没有问题:
byte value1 = 10;
byte value2 = 23;
long total; // 顺利编译
total = value1 + value2;
Console.WriteLine(total);
将结果值存储到long类型变量里完全没有问题是因为long类型可以存储比int类型更多的字节的数据,所以完全没有数据丢失的风险。在这些情况下,编译器很乐意自动帮你完成这种转换,而不用你特意多写一些显式声明的代码。
下面的表格显示了C#支持的隐式类型转换:
从 | 到 |
---|---|
sbyte | short, int, long, float, double, decimal, BigInteger |
byte | short, ushort, int, uint, long, ulong, float, double, decimal, BigInteger |
short | int, long, float, double, decimal, BigInteger |
ushort | int, uint, long, ulong, float, double, decimal, BigInteger |
int | long, float, double, decimal, BigInteger |
uint | long, ulong, float, double, decimal, BigInteger |
long,ulong | float, double, decimal, BigInteger |
float | double, BigInteger |
char | ushort, int, uint, long, ulong, float, double, decimal, BigInteger |
注意:BigInteger是一个可以包含任意大小数据的结构体。你可以从较小的类型创建它,通过传递一个number数组来创建一个大数值,或者从一个字符串里转换出一个大数值(huge number)。这个类型实现了各种精确计算(mathematical calculations)。它的命名空间为:System.Numeric。
就像你期待的那样,你可以隐式地将较小的数据类型转换成较大的数据类型,反过来则不行。你也可以在整型和浮点型之间进行转换,然而这里有一点点不同。虽然你可以对相同字节的类型进行转换,比如从int/uint到float或者long/ulong到double,你也可以将long/ulong转换成float。只是这样做你会丢失4字节的数据,不过这只是意味着用float的时候精度比用double的时候要小一些。编译器将这个当成是一个可接收的可能错误,因为对于值的大小来说不影响。你也可以将一个无符号数的变量赋值给一个有符号数的变量,只要无符号数变量取值范围在有符号数的范围内即可。
可空数据类型在隐式转换为值类型的时候需要考虑更多一些:
- 可空数据类型转换成其他可空类型就跟上面表格中的普通类型一样,譬如
int?
可以隐式地转换成long?
,float?
,double?
和decimal?
。 - 普通类型隐式地转换成可空类型也与上面表格中定义的规则比较类似,譬如int类型可以隐式地转换成
long?
,float?
,double?
和decimal?
。 - 可空类型不能隐式地转换成普通类型。你必须使用显式转换。这是因为可空类型有可能代表null值,而普通类型无法存储null值。
6.4.1.2 显式转换
大多数类型之间的转换并不能隐式地进行,并且编译器会报错。以下就是一些无法隐式转换的例子:
- int转short —— 可能数据丢失。
- int转uint —— 可能数据丢失。
- uint转int —— 可能数据丢失。
- flota转int —— 小数点后的所有数据都会丢失。
- 任何数据类型转char —— 可能数据丢失。
- decimal转任何数值(numberic)类型 —— decimal类型内部结构与整型和浮点数并不相同。
int?
转int —— 可空类型可能值为null。
然而,你可以使用强制类型运算符来显式地处理这些转换。当你使用强制转换运算符时,你故意(deliberately)强制编译器进行此种转换,就像下面这样:
long val = 30000;
int i = (int)val; // 有效的强制转换,因为int的最大值为2147483647
你通过将类型名称置于一对括号()
里声明了相应的强制类型转换。如果你很熟悉C语言,就会发现这是强制转换的典型语法。而如果你熟悉C++,C++里还有一种特殊的强制转换关键字,static_case,注意C#并不支持。你需要使用的是更老的C风格的语法。
强制转换可能会承担额外的操作风险。即使是一个简单的从long到int类型的转换都可能会出现问题,当long的值比int的最大值还大时:
long val = 3000000000;
int i = (int)val; // 无效的转换,输出的是-1294967296
在这种情况下,你既不会得到一个编译器的错误提示,得到的结果也不是你想要的。
最好假设显式数据转换可能会得不到你预期的数据。就像我们早些时候提到的,C#提供了一checked操作符允许你检查某个操作中可能的算术溢出(arithmetic overflow)。你可以使用checked运算符来保证强制转换的安全并且强制使运行时在溢出时抛出一个OverflowException异常:
long val = 3000000000;
int i = checked((int)val); // System.OverflowException: Arithmetic operation resulted in an overflow
请牢记所有的显式强制转换都可能是不安全的,记得处理你代码中可能因强制转换引起的异常。第14章,"错误和异常"里将会介绍如何通过try和catch代码块来处理异常。
使用强制转换,你可以将大部分的普通数据类型转换成别的数据类型,例如,在下面这段代码中,price的值增加了0.5,并且最后结果被强制转换为int类型:
double price = 25.30;
int approximatePrice = (int)(price + 0.5);
这种操作返回和price最接近的整数美元值。然而,在这种转换中,小数部分的数据丢失了——换句话说,就是小数点之后的内容都丢失了。因此,假如你想接着对这个修改后的price进行其他运算的时候,不能提前进行这样的数据转换,因为精度丢失了。尽管如此,这种转换方式可以让你输出某次完整或不完整计算的近似值(approximate value)——假如你不想让你用户看到一串很长的小数的话。
接下来这个例子展示了当你试图将一个无符号整数转换成char类型时的情况:
ushort c = 43;
char symbol = (char)c;
Console.WriteLine(symbol); // +
输出的字符在ASCII表里的序号为43,也就是+
号。你可以尝试将任何数值类型转换成char,譬如从decimal转换成char,反之亦然:
decimal d = 43.01m;
char symbol = (char)d;
Console.WriteLine(symbol); // +
值类型之间的转换并不限于个别变量,如下所示,你也可以转换数组元素,将它转换成某个结构体的成员变量:
struct ItemDetails
{
public string Description;
public int ApproxPrice;
}
//…
double[] Prices = { 25.30, 26.20, 27.40, 30.00 };
ItemDetails id;
id.Description = "Hello there.";
id.ApproxPrice = (int)(Prices[0] + 0.5);
将一个可空类型转换成普通类型或者另外一个可空类型可能会引起数据丢失,因此你需要使用显式强制转换。即使将可空类型强制转换成它的内置类型时也是这样的——例如int?
转int或者float?
转float的时候。这是因为可空类型可能存在null值,它不可能用普通类型来表示。只要在两个不相同的普通类型之间可以进行强制转换,那么相应的可空类型之间也可以进行强制转换。此外,当试图将一个值为null的可空类型强制转换为普通类型时,将会抛出一个InvalidOperationException,就像下面这样:
int? a = null;
int b = (int)a; // Nullable object must have a value.
通过显式类型转换,只要你够小心,你就可以将任何值类型的实例转换成其他大部分的类型。然而,在显式类型转换中还是存在各种限制的——就值类型来说,你只能在数值类型之间互相转换,或者与char和enum之间转换。你无法将Boolean直接转换成任何类型,反之亦然。
假如你想将数值类型与字符串进行相互转换,你可以使用.NET类库提供的方法。Object类实现了ToString方法,可以被所有.NET的预定义类型进行重写,用来返回一个对象代表的字符串:
int i = 10;
string s = i.ToString();
类似地,假如你需要将一个字符串转换成一个数值型或者布尔值,你可以使用预定义类型内置的Parse方法:
string s = "100";
int i = int.Parse(s);
Console.WriteLine(i + 50); // Add 50 to prove it is really an int
注意当Parse方法无法转换一个字符串时,它会抛出一个异常(例如你想将"Hello"转换成一个整数的时候)。同样的,这部分内容会在第14章进行介绍。
6.4.2 装箱和拆箱
在第2章的时候你已经学习了所有的类型——包括iandan的预定义类型,如int和char,又或者复杂类型,如类和结构体——都是派生自object类型。这意味着你可以将字面量值(literal values)当成object来看待:
string s = 10.ToString();
除此之外,你还了解到C#的数据类型划分成值类型和引用类型,其中值类型存储在栈上而引用类型存储在托管堆上。假如int仅仅只是在栈上保存了4字节的数据值,那么int这块哪来的方法进行调用呢?
C#通过一个叫装箱(boxing)的小魔术来实现这一点。装箱与它的对应操作,拆箱,使得你可以将值类型转换成引用类型,或者从引用类型转换成值类型。我们将这一节的内容,放在"类型强制转换"这里一块讲,是因为本质上你所作的操作就是——将你的数据值强制转换成了object类型,这个从值类型转换成引用类型的操作术语,就叫做装箱。基本来讲,运行时为所需的object对象创建了一个临时的引用类型盒子,并存储在托管堆上。
这个转换是隐式的,就像上面那个例子一样,当然你也可以将它显式地声明,就像下面这样:
int myIntNumber = 20;
object myObject = myIntNumber;
拆箱则是用来描述引用类型转值类型操作的属于,借此前面经过装箱的值类型又重新地被强制转换成了值类型。这里我们用"强制转换"这个术语是因为拆箱这个操作必须是显式地。拆箱的语法和我们曾经介绍过的强制转换很类似:
int myIntNumber = 20;
object myObject = myIntNumber; // 装箱
int mySecondNumber = (int)myObject; // 拆箱
只有当一个变量曾经装箱,我们才可以对相应的装箱对象进行拆箱。假如最后一句的myObject不是一个装箱之后的int,就跟平常一样你想简单地将一个object转换成int你只会得到一个InvalidCastException异常。
object o = new object();
int i = (int)o; // Unable to cast object of type 'System.Object' to type 'System.Int32'
这里需要提醒一点的是:当拆箱的时候,你要小心拆箱后的类型是否容得下被装箱的原始类型。举个例子:C#的int类型,只能存储4字节的数据,假如你拆箱一个long值(8字节),赋值给一个int,像下面这样,同样会提示InvalidCastException,这点与long变量的实际值是否在int取值范围内无关:
long myLongNumber = 1;
object myObject = (object)myLongNumber;
int myIntNumber = (int)myObject; // Unable to cast object of type 'System.Int64' to type 'System.Int32'
6.5 比较对象是否相等
在讨论了运算符并且短暂地接触了等号运算符(equality operator)之后,在class和struct的实例之间相等究竟意味着什么,是一个值得花点时间考虑的问题。了解对象之间相等的判断机制对编写逻辑表达式非常关键并且对于实现运算符和强制转换的重载至关重要。这也是本章剩下部分要讲的所有内容。
对象之间是否相等完全取决于你是比较两个引用类型(类的实例之间)或者是值类型(如基础数据类型,struct实例,或者枚举类型)。接下来的小节将会分开讲述引用类型和值类型之间的相等性判断。
6.5.1 比较引用类型是否相等
你可能会很惊讶,System.Object定义了3个不同的方法用来比较对象是否相等:一个ReferenceEquals方法,一个静态Equals方法以及一个实例Equals虚方法。你也可以实现接口IEquality<T>,它定义了一个Equals方法,带有一个代替object类型的泛型类型参数。除了这些之外,你还可以使用比较运算符==
。事实上你拥有很多种比较对象相等的方法。这些方法之间有些不易察觉的差别,我们将在接下来一一讲述。
6.5.1.1 ReferenceEquals方法
ReferenceEquals是一个静态方法,用来测试是否两个引用都指向同一个类的实例,具体来说就是两个引用是否具有同一个内存地址。作为一个静态方法,它无法被重写,所以System.Object类里的这个方法就是唯一的ReferenceEquals版本。当两个引用指向的是同一个对象实例时,ReferenceEquals会返回true,否则返回false。然而,你可以想想null和null值比较:
static void ReferenceEqualsSample()
{
SomeClass x = new SomeClass(), y = new SomeClass(), z = x;
bool b1 = object.ReferenceEquals(null, null);// returns true
bool b2 = object.ReferenceEquals(null, x); // returns false
bool b3 = object.ReferenceEquals(x, y); // returns false because x and y references different objects
bool b4 = object.ReferenceEquals(x, z); // returns true because x and z references the same object
}
6.5.1.2 Equals虚方法
System.Object里实现了一个的Equals虚方法,也可以用来比较引用。除此之外,因为这个方法声明成了virtual,因此你也可以在你自己的类里面重写它,用来按照你自己的需要进行比较。尤其是,当你将你自己的类作为字典的键值的时候,你必须重写这个方法,以便能进行值的比较。另外,取决于你如何重写Object.GetHashCode,包含你自己类对象的字典可能会完全没法用或者非常低效。注意当你重写Equals方法的时候,你的实现必须不会出现任何异常。进一步讲,如果你重写的Equals出异常将会引起一些其他的问题,因为不单单只是将你的类应用在字典中时会出问题,一些其他的.NET基础类也会内部调用到这个方法。
6.5.1.3 Equals静态方法
静态Equals方法和实例版本的Equals方法实际上干的事都一样。唯一的区别就是静态版本的Equals方法带有两个参数并且比较这两个参数是否相当,实例版本就一个参数而已。这个方法也可以用来处理两个参数都是null的清况。因此,它提供了额外的安全机制来确保当某个参数可能为null的时候不抛出异常。静态Equals方法首先判断传递过来的是不是null,假如两个参数都是null,返回true(因为null被认为是与null相等的)。假如只有其中一个是null,则返回false。假如两个引用都有值,则调用实例版本的Equals方法。这意味着当你在自己的类中重写了Equals方法的话,效果跟你重写了静态版本一样。
6.5.1.4 比较运算符( == )
最好思考一下比较运算符作为严格值比较和严格对象比较之间的中间选项。在大部分情况下,下面的代码意味着你想比较两个对象之间的引用是否相同:
bool b = (x == y); // x, y object references
然而,有些类可能当做值进行比较会更加地直观一些。在那些情况下,最好的方式就是重写比较运算符来执行值比较。重载运算符将在下一节讨论,我们这里先介绍一个最明显的例子System.String类,Microsoft重写了它的==
运算符来比较两个字符串的内容而非比较字符串的引用。
6.5.2 比较值类型是否相等
在比较两个值类型是否相等时,遵循与引用类型相同的原则:ReferenceEquals用来比较引用,Equals用来比较值,而==
运算符则是折衷方案(intermediate case)。然而,最大的差异在于,值类型需要先装箱成引用类型,方法才能执行。另外,Microsoft已经在System.ValueType类重载了实例Equals方法,来测试值类型的相等性。假如你调用sA.Equals(sB),这列sA和sB都是结构体的实例,返回值为true或者false,取决于sA和sB里所有的字段值是否都相同。另一方面,你自己创建的结构体,默认不支持==
运算符重载。直接写sA == sB会导致一个编译错误,除非你在自己的代码里提供了==
的重载。
另外一个点就是,对值类型调用ReferenceEquals经常会返回false,这是因为调用这个方法的时候,值类型会被装箱成object类型。就算你对同一个值类型进行调用,结果仍然是false:
bool b = ReferenceEquals(v,v); // v 是值类型,b 为false
原因是v这里分别进行了两次装箱,这意味着你获得的是两个不同的引用。因此,对于值类型来说没有必要调用ReferenceEquals进行比较因为它没有任何意义。
虽然System.ValueType提供的重载Equals方法几乎已经满足大部分struct的需要,你还是可以在你自己的结构体内重写它来提高性能。并且,假如一个值类型包含引用类型作为字段,你可能想通过重写Equals方法为这些引用类型的字段提供更合适的应用场景,因为默认的Equals方法仅仅会简单地比较它们的内存地址。
6.6 运算符重载
本小节着眼于另外一种你可以为class和struct定义的成员:运算符重载。运算符重载对于C++程序员来说很熟悉。但对于Java和VB程序员来说是个新概念,我们将在本章进行阐述。C++程序员可以直接跳过开头的介绍,直接查看运算符重载的示例。
在你不想经常调用某个类的属性或方法时,使用运算符重载会很有用。你常常需要将数量进行相加,相乘,又或者在不同对象之间进行比较。假设你定义了一个类,用来表示矩阵。在数学世界里,矩阵是可以相加和相乘的,就像数字一样。因此,你可能经常会需要像下面这么书写代码:
Matrix a, b, c;
// assume a, b and c have been initialized
Matrix d = c * (a + b);
通过运算符重载,你可以告诉编译器+
和*
用在Matrix对象之间时如何进行计算,让你上面写的代码得以实现。假如你在一门不支持运算符重载的语言中实现相同的功能的话,你可能需要定义相关的方法来实现这些操作。就像下面这样,并不直观:
Matrix d = c.Multiply(a.Add(b));
正如你曾经所学的一样,像+
和*
这样的运算符可以在预定义类型中严格使用,因为编译器知道那些预定义类型和这些运算符之间要如何进行处理。例如,编译器知道如何在两个long类型的数字之间进行相加,也知道如何在两个double类型之间进行除法运算,并且它可以生成合适的IL代码。当你定义了你自己的class或者struct时,无论如何,你都需要告诉编译器每一步该如何做:方法是否可以被调用,哪些字段是实例字段,等等。同样地,如果你想将运算符应用到你自定义的类型上时,你也需要告诉编辑器,对于你的类型来说,相关的运算符在类的上下文中具体具体如何实现。你通过重载运算符来实现这一点。
另外一件要强调的事情是,重载不单单仅限于算数运算符。你同样需要考虑比较运算符,如==
,<
,!
等等。考虑一下if (a == b)
这样的语句,对于类class来说,默认这语句是比较a和b的引用。它检测两者的引用是否指向同一块内存地址,而非检查这俩实例是否拥有同样的数据。对于string类来说,==
运算符就被重写了,因此在比较两个字符串的时候确实是在比较两个字符串的内容是否一致。你可能也想让你自己的类也达到这种效果。而对于结构体struct来说,==
运算符默认是无效的。尝试比较两个结构体是否相等只会产生一个编译错误,除非你显式地重载了==
来告诉编译器如何实现这个比较。
在大部分情况下,运算符重载使你能够写出更具可读性并且更直观的代码,包括下面这些:
- 大部分数学类的对象比如坐标,矢量,矩阵,张量,函数等等。假如你编写的程序需要进行数学或者物理的建模,你肯定会用到这些类来代表相应的对象。
- 使用数学或者坐标相关对象的图形程序,用来计算屏幕位置时。
- 某个用来代表大量金钱的类(例如,在金融程序中)。
- 词汇处理或者文本分析程序,其中用到了句子,从句等等类。你可能想要使用运算符来合并语句(在一种更加复杂的情况下处理字符串拼接)。
尽管如此,依然有很多类型跟运算符重载没有关系。不恰当地使用运算符重载反而会使得那些使用你自定义类型的代码变得更加难以理解。例如,将两个DateTime对象相加毫无意义。
6.6.1 运算符的工作方式
要理解运算符如何重载,先明白编译器遇到一个运算符的时候会如何进行处理将很有帮助。下面我们以+
运算符为例,假定编译器将要处理以下这段代码:
int myInteger = 3;
uint myUnsignedInt = 2;
double myDouble = 4.0;
long myLong = myInteger + myUnsignedInt;
double myOtherDouble = myDouble + myInteger;
先看第一个加法语句,考虑以下编译器遇到这行代码时会如何做:
long myLong = myInteger + myUnsignedInt;
编译器理解它将要对两个整数进行相加,并将结果赋值给一个long类型。尽管右侧的表达式确实很直观,也是一个很便利的语法,调用某个方法来对两个数字进行相加。这个方法带有两个参数,myInteger和myUnsignedInt,并且返回它俩的和。于是,编译器就跟处理其它类型的函数一样:它根据参数类型查找+
运算符最合适的重载——在本例中,就是查找一个能处理两个整数的重载方法。就跟普通的重载方法一样,返回值的类型并不会影响编译器选择哪个版本的方法。上面这个例子最后找到的是接收两个int参数并返回int类型的方法版本,并且随后的返回值被转换(Convert)成了long类型。
其实,对于最后的这一句话,先调用int相加再转换成long,在VS2019里进行试验,并非如此,考虑以下代码:
var myLong = myInteger + myUnsignedInt;
最后得到的myLong同样是long类型,说明编译器知道右侧计算出来的结果应该是long类型,并不存在结果最后转long这一说。笔者认为,右侧是一个int加一个unsignedInt,两者之间无法直接进行隐式转换,编译器最后的处理应该是,将两者都隐式转换为能同时容纳下这两者的类型,譬如long,实际上对于编译器来说,上面执行的应该是:
var myLong = (long)myInteger + (long)myUnsignedInt;
也就是应该先转换long,再进行相加,并不存在计算出int类型的和之后再将结果转换成long这样的过程,因此笔者认为书中这一块说的不对。
例子中最后一行是浮点数与整数进行相加:
double myOtherDouble = myDouble + myInteger;
在这个例子中,参数类型分别是double和int,并不存在任何一个+
运算符的方法重载可以用来接收这两参数类型。取而代之的是,编译器发现,最合适的重载应该是接收两个double类型参数的版本,因此,编译器隐式地将int转换成了double。两个double类型的数进行相加和两个整数进行相加需要不同的处理。浮点数是通过尾数(mantissa)和指数(exponent)进行存储的。对它们进行相加将会包含位移动(bit-shifting)操作,以便两数拥有相同的指数,然后再将尾数进行相加,对结果的尾数进行转换,使得结果能包含最高的精度。
现在你可以开始了解编译器如何处理像这样的操作:
Vector vect1, vect2, vect3;
// initialize vect1 and vect2
vect3 = vect1 + vect2;
vect1 = vect1 * 2;
在这里,Vector是一个结构体,我们下一章将会定义它。编译器发现它需要将两个Vector实例进行相加,它尝试着找到一个+
重载方法,这个方法接收两个Vector类型的实例作为参数。
假如编译器能找到合适的重载,它将会调用运算符的实现。假如它找不到,它会检查是否有其他的+
重载适合处理这种情况——可能有某些其他的类型实现了+
重载,参数虽然不是Vector,但可以隐式地转换成Vector类型。假如编译器还是不能找到合适的重载方法,它将会抛出一个编译错误,因为它找不到任何重载方法可以处理这个操作。
6.6.2 运算符重载的示例:Vector 结构
本小节将通过开发一个名为Vector的结构体来演示运算符重载。这个结构体代表了一个三维数学向量。即使数学不是你的强项也不用担心——我们仅仅只用最简单的部分进行示例。就像你了解的那样,3D向量仅仅只是一个包含3个数字(double类型)的数据集,用来告诉你事物是如何移动。它包含着3个变量,_x
,_y
和_z
:其中_x
代表水平面东西向移动,_y
代表的是水平面上南北向移动,而_z
则垂直水平面的上下移动。通过组合这3个变量,你可以得到最终移动的位置。举个例子,(3.0,3.0,1.0)代表着向东移动3个单位,向北3个单位,上升1个单位。
你可以在两个向量或者向量和具体数字之间进行加法或者乘法运算。顺带一提的是,在这里的,我们用到一个术语,标量(scalar),在数学里它代表一个纯粹的数字——在C#里它仅仅是个double。假如你先移动了向量(3.0,3.0,1.0),然后又移动了向量(2.0,- 4.0,- 4.0),最终移动的位置将由两个向量相加决定。向量的相加意味着每个方向独立进行相加,所以你得到了(5.0,- 1.0,- 3.0)。在这里,数学公式写作c=a+b
,其中a和b是向量,并且c为结果向量。你想让你的Vector结构体也能实现这个效果。
注意:本例中使用的是struct而非class并不影响运算符重载,在struct或者class里重载运算符并没有什么两样。
下面是向量结构体Vector的定义——包含只读属性,构造函数和一个重写的ToString方法,你可以轻松地得到向量的信息:
struct Vector
{
public Vector(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public Vector(Vector v)
{
X = v.X;
Y = v.Y;
Z = v.Z;
}
public double X { get; }
public double Y { get; }
public double Z { get; }
public override string ToString() => $"( {X}, {Y}, {Z} )";
}
例子中包含了两个构造函数来指定向量的初始值,或者通过每个方向的值进行初始化或者从另外一个Vector结构体进行拷贝。
像第二个构造函数这样的,包含一个Vector参数,通常被叫作拷贝构造函数,因为它们实际上是通过拷贝另外一个实例中的所有值来初始化一个新的class或者struct实例。
接下来就是我们最有意思的部分了——运算符重载,为Vector类提供了+
运算:
public static Vector operator +(Vector left, Vector right) => new Vector(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
运算符的重载和静态方法很像,除了多了一个operator
关键字以外。这个关键字告诉编译器你定义了一个运算符重载。operator
关键字后紧跟相关运算符的符号,这里我们用的是加法符号+
。返回类型为你使用这个运算符后最终得到的类型,因为我们知道两个向量相加还是向量,因此返回类型为Vector。在这个特殊的加法重载例子中,返回类型与它的容器类型一致,但这并不是必然的,后续你可以看到返回值为其他类型的例子。两个参数left和right是你要用来做加法运算的操作数。对于二元运算符来说,如加法和减法,第一个参数是在运算符左边的,第二个参数则是在运算符右边。
这里的实现返回了一个新的Vector实例,通过left和right变量相加得来。
C#要求所有的运算符重载必须声明成public和static,这意味着它们跟类或者结构体相关,而非具体的实例。因此,运算符重载的方法体无法直接访问非静态的内部成员或者使用this
关键字。这种设计没有问题,因为参数提供了所有的输入数据来完成相应的任务。
现在你可以在Main方法里写一些测试代码:
static void Main()
{
Vector vect1, vect2, vect3;
vect1 = new Vector(3.0, 3.0, 1.0);
vect2 = new Vector(2.0, -4.0, -4.0);
vect3 = vect1 + vect2;
Console.WriteLine($"vect1 = {vect1}");
Console.WriteLine($"vect2 = {vect2}");
Console.WriteLine($"vect3 = {vect3}");
}
执行结果如下所示:
vect1 = ( 3, 3, 1 )
vect2 = ( 2, -4, -4 )
vect3 = ( 5, -1, -3 )
除了对向量进行相加以外,你也可以将它们相乘或者相减,又或者比较两个向量的值。在本小节中,虽然并没有实现所有的向量集的功能,然而用来演示运算符重载的特性已经足够了。接下来我们将会演示向量和标量相乘以及向量与向量之间相乘。
向量与标量之间的乘法运算非常简单,就是向量的每个部分都单独地与标量进行乘法运算,如下所示:
public static Vector operator *(double left, Vector right) => new Vector(left * right.X, left * right.Y, left * right.Z);
这种写法本身来说,还不太足够。假定a和b是两个Vector变量,你可以像下面这么写:
b = 2 * a;
编译器会隐式地将2转成double类型来匹配重载的运算符方法签名。然而,像下面这么写则会得到一个编译错误:
b = a * 2;
这是因为编译器将运算符重载实际上当成方法来看。它检查所有可能的运算符重载方法,上面的语句需要第一个参数为Vector类型并且第二个参数为整型类型的运算符重载,又或者其他能进行参数隐式转换的重载方法。因为你没有提供类似的方法,编译器并不会自动交换两个运算数的顺序,因此你前面提供的那个运算符重载方法不会在这里被用上。你仍然需要显式地定义第一个参数为Vector类型并且第二个参数为double类型的运算符重载。有两种方式实现这一点,譬如像上面那种实现:
public static Vector operator *(Vector left, double right) => new Vector(right * left.X, right * left.Y, right * left.Z);
看起来就跟上面的实现差不多,你可能更喜欢复用上面的代码,如下所示:
public static Vector operator *(Vector left, double right) => right * left;
这种方式简单的交换了两个参数的位置并调用了另外一个运算符重载的实现。本章的示例代码用的是第二种方式,因为它看起来更加简介,并且能贴合乘法运算的特点(交换操作数的位置不会改变乘法的结果)。这种写法的可维护性也更加,因为万一需要进行修改,你只需修改一个方法体,而非两个都要进行修改。
接下来,我们需要实现两个Vector之间的相乘运算。在数学里提供了很多种向量相乘的方式,其中最简单的一种方式是数量积,又叫点积或者内积,返回的是标量值。这也是下面这个示例中要演示的——证明数学运算符的重载并非只能返回所在类的类型。
在数学定义中,假如存在两个向量(x, y, z)和(X, Y, Z),那么内积为x*X + y*Y + z*Z
。虽然这种乘法运算看起来很奇怪,但它实际上对于计算其他数量很有帮助。假如你曾经写过用来展示复杂3D图像的代码,如使用Direct3D或者DirectDraw,你几乎肯定能发现你的代码经常需要处理向量的内积,作为一个最终计算物件在屏幕中显示位置的中间步骤。假如你想使用类似double x = a*b
这样的语法来计算向量a和b的点积的话,相关的重载方法如下所示:
public static double operator *(Vector left, Vector right) => left.X * right.X + left.Y * right.Y + left.Z * right.Z;
现在你可以在代码里测试它们的效果:
static void Main()
{
// stuff to demonstrate arithmetic operations
Vector vect1, vect2, vect3;
vect1 = new Vector(1.0, 1.5, 2.0);
vect2 = new Vector(0.0, 0.0, -10.0);
vect3 = vect1 + vect2;
Console.WriteLine($"vect1 = {vect1}");
Console.WriteLine($"vect2 = {vect2}");
Console.WriteLine($"vect3 = vect1 + vect2 = {vect3}");
Console.WriteLine($"2 * vect3 = {2 * vect3}");
Console.WriteLine($"vect3 += vect2 gives {vect3 += vect2}");
Console.WriteLine($"vect3 = vect1 * 2 gives {vect3 = vect1 * 2}");
Console.WriteLine($"vect1 * vect3 = {vect1 * vect3}");
}
执行程序你将会看到以下的结果:
vect1 = ( 1, 1.5, 2 )
vect2 = ( 0, 0, -10 )
vect3 = vect1 + vect2 = ( 1, 1.5, -8 )
2 * vect3 = ( 2, 3, -16 )
vect3 += vect2 gives ( 1, 1.5, -18 )
vect3 = vect1 * 2 gives ( 2, 3, 4 )
vect1 * vect3 = 14.5
这个结果意味着所有的运算符都得到了正确的重载,但假如你仔细地查看上面这段代码,你可能会为其中的一个运算符感到惊讶,因为你并没有实现它——也就是加法赋值运算符+=
:
Console.WriteLine($"vect3 += vect2 gives {vect3 += vect2}");
这是因为+=
虽然看上去是一个运算符,但其实它的计算分两步走:先进行加法运算,再进行赋值。跟C++不同的是,C#不允许你重载=
运算符,但如果你重载了+
运算符,编译器会自动应用你的+
重载来实现+=
操作。同样的原理也适用于其它的赋值运算符,譬如-=
,*=
等等。
6.6.3 比较运算符的重载
C#有六种比较运算符,可以如下面这样分成3组:
==
和!=
>
和<
>=
和<=
注意:.NET指南里定义了当使用
==
比较两个对象是否相等时,假如有一次为true,那么它必须永远为true。这也就是为何你只能在不可变类型上重载==
运算符。
C#要求你成对地实现比较运算符,换句话说,假如你实现了==
的重载,你就必须也实现!=
,否则你将会得到一个编译错误。另外,比较运算符的返回类型必须是bool类型。这就是比较运算符和算术运算符之间的根本区别。对两个数进行加减,从理论上可以得到任意类型的结果。你也已经看到了将两个Vector对象相乘可以返回一个标量值。另外一个.NET基础类型System.DateTime也很好证明了这一点,你可以将两个DateTime实例进行相减,而结果却不是一个DateTime类型,而是一个System.TimeSpan实例。相比之下,比较运算符假如无法返回bool完全没有任何意义。
除了这些区别之外,重载比较运算符和算术运算符遵循相同的原则。尽管如此,比较两个数通常并不像你想的那么简单。举个例子,假如你简单地比较两个引用对象,你实际上是在比较保存这俩对象的内存地址。这通常不是你想要的实现,所以你需要重载比较运算符,来比较对象的实际值并最终返回相应的true或者false。下面的例子中重载了Vector结构体的==
和!=
运算符。让我们先看一下==
的实现:
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;
}
这种方法简单地通过Vector各自的组件的值是否相等来确定两个Vector对象的相等性。对于大部分struct结构体来说,这可能也是你想要的效果,虽然在某些特殊情况下你可能需要更仔细地思考相等性如何判断。举个例子,假如存在俩个嵌套类的实例,你是应该简单地判断其引用成员是否指向同一个地址(浅比较)还是更细致地判断该引用成员所包含的实际内容是否一致呢(深比较)。
在浅比较中,对象是否指向同一内存地址就是其判断标准,但对于深比较来说,则由对象内部成员和值来确定其相等性。你想进行何种层次的比较完全取决于你想证明啥。
注意:请不要尝试在你重载的
==
中只简单地调用System.Object中实例版本的Equals方法来返回true或者false。假如你这么做了,当代码中试图进行(objA == objB)的比较时,有可能会报错。因为objA可能是null值,编译器实际上是试图执行null.Equals(objB)方法。你可以通过override实例版本的Equals方法来实现相等性的比较,这更安全一些。
就像上面所说的,你需要同时实现!=
运算符的重载:
public static bool operator !=(Vector left, Vector right) => !(left == right);
接下来我们还需要重写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方法反过来可以调用==
运算符,而HashCode的实现则必须足够快,并且对于同一对象,总是返回相同的哈希值,这个方法对于字典非常有用。在字典中,HashCode被用来创建对象树,因此GetHashCode方法的返回值最好在整数范围内。double类型的GetHashCode返回的也是代表double的整数哈希值。而对于上面的Vector类型来说,哈希值是通过其内部的x,y,z各自的哈希值通过异或计算得出。
对于值类型来说,你还必须实现IEquatable<T>接口,比起基类object定义的Equals虚方法来说,这个接口定义的是强类型的版本,基于上面我们已经实现过的代码,你可以很简单地实现它:
private readonly struct Vector:IEquatable<Vector>
{
//...
public bool Equals([AllowNull] Vector other) => this == other;
}
下面我们就又到了喜闻乐见的测试时间:
static void Main()
{
var vect1 = new Vector(3.0, 3.0, -10.0);
var vect2 = new Vector(3.0, 3.0, -10.0);
var vect3 = new Vector(2.0, 3.0, 6.0);
Console.WriteLine($"vect1 == vect2 returns {(vect1 == vect2)}");
Console.WriteLine($"vect1 == vect3 returns {(vect1 == vect3)}");
Console.WriteLine($"vect2 == vect3 returns {(vect2 == vect3)}");
Console.WriteLine();
Console.WriteLine($"vect1 != vect2 returns {(vect1 != vect2)}");
Console.WriteLine($"vect1 != vect3 returns {(vect1 != vect3)}");
Console.WriteLine($"vect2 != vect3 returns {(vect2 != vect3)}");
}
执行结果如下所示:
vect1 == vect2 returns True
vect1 == vect3 returns False
vect2 == vect3 returns False
vect1 != vect2 returns False
vect1 != vect3 returns True
vect2 != vect3 returns True
6.6.4 可以重载的运算符
并非所有的运算符都可以进行重载,下表列出了相应的情况:
分类 | 运算符 | 限制 |
---|---|---|
算术二元运算符 | +, *, /, -, % | 无 |
算术一元运算符 | +, -, ++, –– | 无 |
位二元运算符 | &, |, ^, <<, >> | |
位一元运算符 | !, ~,true,false | true和false必须成对实现 |
比较运算符 | ==, !=,>=, <=>, <, |
比较运算符必须成对实现 |
赋值运算符 | +=, -=, *=, /=, >>=, <<=, %=, &=, |=, ^= |
你无法显式地重载这些运算符, 当你重载如+, -, *, /时自动实现 |
索引器 | [ ] | 无法重载,但你可以实现它 |
类型强制转换 | ( ) | 无法直接重载,本章最后一 小节会介绍如何实现自定义 类型强制转换。 |
注意:你可能会对重载true和false运算符可以重载感到疑惑。事实上,一个整数值能否代表true或者false完全取决于你所使用的技术或者框架。在很多技术中,0代表着false而1代表着true;而有些技术则定义0为false,非0则为true。当你为某个类型实现了true或者false重载,那么该类型的实例直接就可以作为条件语句的判断条件。
6.7 实现自定义的索引运算符
自定义的索引器(indexers)并非使用运算符重载发语法,反而很跟属性的语法非常类似。
让我们先回顾访问数组元素的例子:
int[] arr1 = {1, 2, 3};
arr1[1] = 42;
int x = arr1[2];
在这里,我们声明了一个int类型的数组,第二行代码通过索引的方式访问了数组第二个元素,并且将42赋值给第二个元素。第三行代码则是将数组第三个元素值赋值给变量x。
为了演示自定义索引器的例子,让我们先定义一个Person类,它含有三个属性:
public class Person
{
public DateTime Birthday { get; }
public string FirstName { get; }
public string LastName { get; }
public Person(string firstName, string lastName, DateTime birthDay)
{
FirstName = firstName;
LastName = lastName;
Birthday = birthDay;
}
public override string ToString() => $"{FirstName} {LastName}";
}
接下来我们定义了一个PersonCollection集合类,它带有一个私有的Person数组字段:
public class PersonCollection
{
private Person[] _people;
public PersonCollection(params Person[] people) => _people = people.ToArray();
}
而通过实现索引器,可以访问PersonCollection类的实例并返回其中保存的Person对象。索引器看起来跟属性非常的相似,因为它也包含了get和set访问器。唯一的区别就是名称。定义一个索引器会用到this
关键字。紧随其后的中括号则指定了索引的数据类型:
public Person this[int index]
{
get => _people[index];
set => _people[index] = value;
}
数组的索引器接收的是int类型的索引,如果我们也使用int类型的索引的话,就可以直接作为索引参数传递给Person数组来访问相应的元素。get和set访问器的用法和属性很类似,取值的时候会调用get,而赋值的时候会用到set。
在索引器中,你并非只能定义int类型的索引参数。任何类型都可以,譬如下面这个例子:
public IEnumerable<Person> this[DateTime birthDay]
{
get => _people.Where(p => p.Birthday == birthDay);
}
这里我们用DateTime类型作为索引参数,并且我们返回的并非单一的Person,而是一个可枚举的Person集合。因为有很多人的生日会是同一天,因此我们无法只返回一个人的实例。Where方法通过lambda表达式来过滤符合要求的元素,它定义在命名空间System.Linq下。
索引器使用DateTime类型提供了Person实例的取值,但你无法对其进行赋值,因为它并没有提供set访问器。上面的代码也可以用表达式的方式简写成:
public IEnumerable<Person> this[DateTime birthDay] => _people.Where(p => p.Birthday == birthDay);
接下来我们在Main方法里创建了4个Person类型的实例,并且将它们作为参数,创建一个PersonCollection。在第一个WriteLine方法里,我们输出了第三个元素。而在foreach循环里,我们筛选出了生日为1960年3月21日的Person:
static void Main()
{
var p1 = new Person("Ayrton", "Senna", new DateTime(1960, 3, 21));
var p2 = new Person("Ronnie", "Peterson", new DateTime(1944, 2, 14));
var p3 = new Person("Jochen", "Rindt", new DateTime(1942, 4, 18));
var p4 = new Person("Francois", "Cevert", new DateTime(1944, 2, 25));
var coll = new PersonCollection(p1, p2, p3, p4);
Console.WriteLine(coll[2]);
foreach (var r in coll[new DateTime(1960, 3, 21)])
{
Console.WriteLine(r);
}
Console.ReadLine();
}
运行程序,结果如下所示:
Jochen Rindt
Ayrton Senna
6.8 用户定义的类型强制转换
在早先的"显式转换"章节里,你已经学习了如何在预定义类型之间进行强制转换。你也了解到C#的强制转换分两种,隐式和显式。本小节继续关注这部分内容。
对于显式强制转换来说,你需要使用()
将你想转换的类型包含在内,如下所示:
int i = 3;
long l = i; // implicit, 隐式
short s = (short)i; // explicit, 显式
对于预定义数据类型来说,最好的方式是使用显式转换,因为有时候转换会失败,又或者丢失某些数据,譬如下面列举的这些情况:
- 从int转成short的时候,short可能不足以存下某些比较大的int值。
- 从有符号数转成无符号数的时候,负数将会转换成一个错误的数值。
- 从浮点数转整型的时候,小数部分将会丢失。
- 从可空类型转换为非空空类型时,null值会引起异常。
通过使用显式转换,C#强制要求你明白可能会存在数据丢失,并因此推断你已经考虑到了这一点。
因为C#允许你定义你自己的数据类型(结构体和类),它也考虑到了你可能需要支持那些自定义数据类型之间的强制转换。实现的机制是在相关类中定义强制转换的操作符。你的强制转换操作符必须标记为implicit或者explicit来标识它将如何使用。我们希冀你能像预定义的类型强制转换那样遵守相同的规则:假如你知道某个强制转换总是安全的,无论初始变量的值是什么的时候,你可以将它定义为implicit。相反的,假如你知道可能某些值的强制转换上可能会出现错误——譬如数据丢失或者别的异常——你需要将此强制定义成explicit。
定义强制转换的语法和重载运算符的语法很像。这并非巧合——强制转换也被当成一种运算符,从一种类型转换成另外一种类型。为了演示语法,接下来的例子我们将用到一个叫Currency的结构体:
public static implicit operator float (Currency value)
{
// processing
}
操作符的返回类型定义了强制转换的目标类型,而唯一的参数则定义了要被转换的源类型。这里定义的强制转换允许你隐式地将一个Currency值转换成一个float。注意当一个转换被声明成了implicit的时候,那么无论你是显式地还是隐式地进行转换,编译器都能通过。而如果你将它定义成explicit的时候,编译器仅仅允许显式转换。和其他的运算符一样,强制转换必须定义成public和static。
6.8.1 实现用户定义的类型强制转换
本小节将通过一个叫做CastingSample的例子来演示用户自定义的类型强制转换。在本例中,你定义了一个结构体,Currency,来保存美元($)的数据。C#虽然提供了decimal来实现这一点,但你可能仍然想编写属于你自己的结构体或者类来代表货币值,以便你能实现更加复杂的金融处理,因此你会想在类或者结构体中实现相应的方法。
注意:强制转换的语法对struct还是class来说都是一样的。
Currency结构体如下所示:
public struct Currency
{
public uint Dollars { get; }
public ushort Cents { get; }
public Currency(uint dollars, ushort cents)
{
Dollars = dollars;
Cents = cents;
}
public override string ToString() => $"${Dollars}.{Cents,-2:00}";
}
我们为Dollars和Cents使用了无符号数类型来保证他们必须是非负数。这是为了后续演示显式转换而加的限制。你可能也会想使用一个类来存储这样的结构体,用来表示雇员薪水,譬如Salary类,员工的工资可不能为负数。
假定你想将一个Currency实例转化成float类型,其中整数部分代表Dollars,而小数部分代表Cents。换句话说,你可能会需要这么写:
var balance = new Currency(10, 50);
float f = balance; // We want f to be set to 10.5
为了实现这一点,你需要定义一个强制转换。然后你可能会这么写:
public static implicit operator float(Currency value) => value.Dollars + (value.Cents/100.0f);
上面的定义是隐式地,这也很好理解,任何能存在Currency(货币)里的值也可以存在float里。这里有个障眼法:事实上如果你将uint转换成float的话,可能会有精度丢失,但是Microsoft认为这个误差是可以接受的,因此将uint转float也定义成implicit的。
然而,当你想把一个float值转换成Currency的时候,就需要定义新的转换。float类型的值可以存储负数,但我们上面定义了Currency只允许存储非负数,并且float能存储的数值远大于Currency中uint类型的Dollars所能存储的数值。因此,假如一个float不是一个合适的值时,将它转换成Currency会导致不可预期的结果。因为存在这样的风险,将float转换成Currency必须声明成explicit的。下面是第一种做法,可能无法返回正确的值,但它具有教育意义:
public static explicit operator Currency (float value)
{
uint dollars = (uint)value;
ushort cents = (ushort)((value-dollars)*100);
return new Currency(dollars, cents);
}
通过这个定义,显式地转换是可以的:
float amount = 45.63f;
Currency amount2 = (Currency)amount;
而如果你省略显式声明,则会提示一个编译错误:
float amount = 45.63f;
Currency amount2 = amount; // wrong
通过要求显式转换,你告诫研发人员更小心地使用该转换,因为中间可能会出现数据丢失。然而,你很快就看到,这样的Currency并非是你想要的。让我们尝试编写一个测试约束(write a test harness),并在其中运行示例。以下是Main方法的内容,首先它初始化了一个Currency结构体并且尝试进行一些转换。在开始的时候,你通过两种方式输出了balance的值——这是为了稍后演示内容的需要:
static void Main()
{
try
{
var balance = new Currency(50,35);
Console.WriteLine(balance);
Console.WriteLine($"balance is {balance}"); // implicitly invokes ToString
float balance2 = balance;
Console.WriteLine($"After converting to float, = {balance2}");
balance = (Currency) balance2;
Console.WriteLine($"After converting back to Currency, = {balance}");
Console.WriteLine("Now attempt to convert out of range value of " + "-$50.50 to a Currency:");
checked
{
balance = (Currency) (-50.50);
Console.WriteLine($"Result is {balance}");
}
}
catch(Exception e)
{
Console.WriteLine($"Exception occurred: {e.Message}");
}
}
注意所有的代码都被置于try-catch代码块中来捕获你在强制转换过程中可能会出现的任何异常。另外,我们还在checked代码块里尝试将一个负数进行转换,运行Main方法,结果如下所示:
50.35
balance is $50.35
After converting to float, = 50.35
After converting back to Currency, = $50.34
Now attempt to convert out of range value of -$50.50 to a Currency:
Result is $4294967246.00
我们的代码似乎并未得到想要的输出。首先,从Currency类型转回float的时候得到了一个错误的值:$50.34,而非$50.35。其次,当你试图强制转换一个超出原先设计范围的负数时,并没有抛出相应的异常。
第一个问题是因为4舍5入导致的(round errors)。假如一个数值从float转成uint类型,计算机会将多余的部分舍弃(truncate),而非进行四舍五入计算。计算机存储数值的时候用的是二进制而非十进制,而在二进制中,像0.35这样的小数并不能精确地进行表示(就像1/3无法用十进制准确表示一样,它只能用0.3333...3来表示)。计算机实际上存储的是一个比0.35略微小一点点的值,这个值可以在二进制中准确表示。将其乘以100的话,你将会得到一个略微小于35的数34.9998474
,截断之后你只能得到34美分。很显然,在这种情况下,截断(truncation)引发的错误将会很严重,而避免这种错误出现的方法是确保在数值转换的过程中使用一些智能的四舍五入方式。
幸运的是,Microsoft提供了System.Convert类来处理这种情况。System.Convert类包含了大量的方法来处理各种各样的数值转换,譬如其中有一种方法叫Convert.ToUInt16。需要注意的是System.Convert类里的方法会需要额外的性能开销,你最好在必要的时候再使用它们。
让我们再考虑一下第二个问题——为何预期的溢出异常没有被抛出。这里的问题是:实际上出现溢出的地方并非存在于Main方法里——它存在于强制转换运算符的内部实现代码,而这部分代码并未被标记成checked。
解决方案是在强制转换运算符的代码处加上checked标记,再考虑到第一个问题的时候我们提到的使用Convert的方法,最终修改成的强制转换代码就像下面这样:
public static explicit operator Currency (float value)
{
checked
{
uint dollars = (uint)value; // 这一步在转换负数时会出现溢出
ushort cents = Convert.ToUInt16((value-dollars)*100);
return new Currency(dollars, cents);
}
}
注意你使用了Convert.ToUInt16来计算美分,就像前面说的一样,然而你不能将它用在计算美元上。System.Convert无需要用在计算美元部分,因为这里你想要的就是截断float的小数部分,提取其整数数值。
注意:System.Convert方法在它们内部自己实现了溢出检查。因此,实际上我们不需要将Convert.ToUInt16方法包含在checked代码段中。当然checked仍然是需要的,因为dollar部分的处理有可能溢出。
通过这个新加的checked代码,你并不会看到一组新的结果集因为你仍需对CastingSample进行一些修改,这部分我们会在后续进行介绍,引用新的float转Currency方法后,结果如下所示:
50.35
balance is $50.35
After converting to float, = 50.35
After converting back to Currency, = $50.35 // 不再是$50.34
Now attempt to convert out of range value of -$50.50 to a Currency:
Exception occurred: Arithmetic operation resulted in an overflow. // 正常抛出异常
注意:假如你定义的类型转换经常会被调用,那么在某些以性能为最优先的情况下,你可能更愿意不进行任何错误检查。这通常也是合情合理的,前提是你清楚描述了你的类型转换代码实现过程并且说明了该转换内部未实现任何错误检查。
6.8.2 不同类之间的强制转换
Currency例子中仅仅包含了类型与系统预定义类型float之间的互相转换。然而,其实这种简单的转换没有必要特意去实现它。更常见的情况是,你可能会在你自己定义的不同的struct或者class的实例之间进行转换。你需要注意的是这之间有不少限制条件:
- 假如一个类派生自另外一个类,你无法在这两者之间再次定义强制转换(因为它们之间的转换已经实现了)。
- 强制转换必须定义在互相转换的类型中,定义在它们俩中哪一个都行,但不能定义在其他第三者的类型中。
为了演示上面这些要求,我们假定你拥有以下结构关系的类:
换句话说,C和D间接派生于A。在这种情况下,唯一合法的强制转换仅能存在于C和D之间,因为它俩之间没有任何派生关系。他们之间的转换代码可能像下面这样(通常我们定义成explicit):
public static explicit operator D(C value)
{
//...
}
public static explicit operator C(D value)
{
//...
}
你可以将上面的代码放在C中也可以放在D中或者分开放,但不能放在C和D以外的地方。这是因为考虑到你需要有权限编辑要进行类型转换的类,哪怕只能访问其中一个。这点非常有意义,因为它阻止了第三方通过类型强制转换破坏你的程序。
当你在某个类中定义了一种强制转换,你不能将它重复定义到另外一个类中。很显然,对于一种类型转换,仅能存在一份有效代码,否则编译器不知道应该调用谁。
6.8.3 基类和派生类之间的强制转换
为了了解它们之间时如何进行转换的,让我们先假定两者都是引用类型,存在两个class,一个叫MyBase,一个叫MyDerived,其中MyDerived可能直接或者间接派生自MyBase。
首先,从MyDerived到MyBase,下面这么写总是可行的:
MyDerived derivedObject = new MyDerived();
MyBase baseCopy = derivedObject;
在这里,你将MyDerived隐式地转换成MyBase。它之所以能成功是因为这样一条规则:基类的引用,可以引用基类的对象实例,也可以引用它派生类中的对象实例。在面向对象编程中,派生类的实例,实际意义上,其实是基类的实例,只不过多了些额外的东西。所有基类中定义的成员,派生类中都有,派生类中拥有的内容,完全足够构造一个基类的实例。
换一种方式,假如你这么写:
MyBase derivedObject = new MyDerived();
MyBase baseObject = new MyBase();
MyDerived derivedCopy1 = (MyDerived) derivedObject; // OK
MyDerived derivedCopy2 = (MyDerived) baseObject; // Throws exception
在C#里,最后一行代码执行的时候会抛出异常。当你尝试对类型进行强制转换时,类型引用的对象实例会接受检查。因为一个基类引用,原则上,也可以指向派生类的实例,因此当其指向的对象实际上是派生类的实例时,尝试将它重新转回派生类引用是可行的。然而,尝试将一个实际上的基类实例转化成任何派生类实例的时候,就会失败并抛出异常。
注意那些编译器支持的基类和派生类之间的转换,实际上不会涉及到实际对象的任何数据转换。它们所做的只是,在转换是合法的时候,将新的引用指向对象实例。这种情况下,这些转换和你自己定义的那些类型转换其实是不同的。譬如在前面的CastingSample例子中,你定义了结构体Currency和预定义类型float之间的强制转换。在float转Currency的过程中,实际上你是新建了一个Currency结构体的实例,并用相应的数值初始化了它。而系统内置的基类和派生类之间的转换则不需要这么做。假如你想将一个实际上是基类的实例转换成一个派生类的实例,你无法通过强制转换语法来实现。最好的方式是在派生类里定义一个构造函数,将基类实例作为参数传入,然后进行相应的初始化,如下所示:
class DerivedClass: BaseClass
{
public DerivedClass(BaseClass base)
{
// initialize object from the Base instance
}
}
6.8.4 装箱和拆箱转换
前面的讨论主要关注的是基类和派生类之间的转化,并且它们都是引用类型。在值类型之间的转换原则也很类似,只不过在它们之间并非简单的拷贝引用,实际上它们需要拷贝整个数值。
当然,你无法从一个结构体或者基础数据类型中派生子类。在父类和结构体之间的强制转换总是意味着将基础类型或者结构体强制转换成System.Object。(理论上,你也可以将结构体和System.ValueType进行转换,只不过很少见到而已。)
从任何结构体(或者基础数据类型)到object之间的强制转换总是可行的并且是隐式地——因为它是从派生类转换到基类——并且它是一个装箱的过程。例如使用Currency结构体:
var balance = new Currency(40,0);
object baseCopy = balance;
当执行这个隐式转换的时候,balance的内容将会被拷贝到托管堆上,置于一个装箱对象(boxed object)里,而baseCopy则引用这个装箱对象。这个场景实际上是因为:当你最初定义Currency结构体的时候,.NET Framework会隐式地提供另外一个隐藏类,一个装箱后的Currency类,拥有与结构体Currency相同的字段和成员,只不过它是一个引用类型而已,保存在托管堆上。无论什么时候你定义一个值类型,不管它是struct也好,enum也罢,并且对于基础数据类型,如int,double,uint等等,都存在相似的装箱引用对象。虽然你不可能,也没必要直接在代码里操作这些内部实现的装箱对象,但它们确确实实在你需要将值类型转换成object时生效。当你隐式地将Currency转换成object的时候,装箱后的Currency类实例将会被创建,并且通过Currency结构体的所有数据进行初始化。
我们知道另外一种转换的情况被称之为拆箱。就像从一个基类引用转换成派生类引用一样,它必须是显式的声明,因为当被转换的实例不是正确的对象的时候会产生异常:
object derivedObject = new Currency(40,0);
object baseObject = new object();
Currency derivedCopy1 = (Currency)derivedObject; // OK
Currency derivedCopy2 = (Currency)baseObject; // Exception thrown
这段代码的结果跟上面我们讲的基类和派生类之间的很像。将derivedObject转换成Currency能生效是因为derivedObject实际上引用的就是一个装箱后的Currency实例——实际上的转换就是从装箱后的Currency类实例将所有值都拷贝到新的Currency结构体derivedCopy1中。而第二行的derivedCopy2转换失败了是因为baseObject本身指向的并非是一个装箱Currency实例。
当使用装箱和拆箱时,非常重要的一点是明白这俩过程实际上是对装箱对象或者拆箱后的结构体进行操作。因此,操作装箱后的对象,不会影响到原始的值类型。
6.9 多级强制转换
有一件事你需要小心的是,当你尝试对两个类型之间进行转换,而C#编译器在处理时,发现两者之间没有直接的转换关系,它会尝试是否存在二次以上的转换能最终实现这个转换效果。例如,还是我们的Currency示例,假定你想这样做:
var balance = new Currency(10,50);
long amount = (long)balance;
double amountD = balance;
首先你初始化了一个Currency实例,并且你尝试将它强制转换成long类型。麻烦在于你并未定义这样的强制转换。然而,这样的代码依然可以成功运行。这是因为:比那一起发现你曾经定义了一个Currency到float的隐式转换,并且编译器已经知道float到long之间的转换被声明成了显式转换。因此它将这段代码编译成IL的时候,首先是将balance转换成float,然后将返回值再转换成long。最后一行也是同样的处理过程,当你将balance转成double的时候,因为Currency转float和float转double都是隐式声明的,因此你可以直接隐式地将balance赋值给double类型的变量。事实上假如你不嫌麻烦的话,你也可以显式地书写整个转换过程:
var balance = new Currency(10,50);
long amount = (long)(float)balance;
double amountD = (double)(float)balance;
然而在大部分情况下,这只会让你的代码变得更加复杂而已,没太大的必要。假如你省略了long这一步的强制转换,如下所示,将会提示一个编译错误:
var balance = new Currency(10,50);
long amount = balance;
这是因为编译器能找到的转换路径是Currency转float再转long,而从float到long之间的转换需要显式声明,因此编译器会提示一个错误信息。
这一切本身并不会给你造成太大的麻烦。毕竟,这个规则的设计相当直观,为了让程序员无需了解更多的内容,又能避免数据丢失。尽管如此,问题仍然存在,假如你在定义你自己的转换时不那么细心,有可能会导致编译器在转换的时候选择了一条错误的转换路径,结果将无法预期。举个例子,假如组内的其他人打算在Currency结构体里编写了一个新的转换,使得可以将一个单位为美分的uint数值转成Currency结构体,那么他可能会像这样实现:
// Do not do this!
public static implicit operator Currency (uint value) => new Currency(value/100u, (ushort)(value%100));
注意在第一个100后面带着小写的u
来保证value/100u的结果为uint类型。假如你省略了这个u,那么编译器默认结果为int类型,而非uint。
代码上面的注释很清楚地写了"不要这么做",为什么呢?让我们先看一下下面这个例子,假设bal变量现在是350美分,我们试图将它转换成Currency进行存储,然后再转换回来。那么你认为bal2将会是什么值?
uint bal = 350;
Currency balance = bal;
uint bal2 = (uint)balance;
答案是3而非350,但是,这个结果是符合逻辑的。你隐式地将350转换成Currency,此时,balance.Dollars=3,balance.Cents=50。然后下一行,编译器能找到的最合适的路径是先隐式地转换成float(3.5),然后再显式地从float转换成unit,因此结果为3。
当然,其他类型的实例在不同数据类型之间转换当然也可能会存在数据丢失。譬如,你将一个值为5.8的float类型转成int然后再将int转回float的时候,它将丢失小数部分,给你返回一个5.0的值,但这仅仅只是很小的区别因为它仅仅损失了小数部分而已,而上面例子中的350和3可是接近100倍的差距。Currency现在变成了一个很危险的类型尤其在当它进行整数的处理的时候。
这里的问题在于你定义的转换对于整数的解释存在分歧。Currency和float之间的转换你定义了整数1代表1美元,而稍后的uint转Currency的时候则将1代表1美分。这是一个不可取的设计。假如你想让你的类型更加容易使用,你必须确保你定义的所有转换是相互兼容的,这意味着直观上来看,它们给出的结果是一致的。因此,在这种状况下,解决方案是将uint转Currency改成1代表1美元的情况:
public static implicit operator Currency (uint value) => new Currency(value, 0);
顺带一提,你可能会疑惑这个新的转换是否有必要,答案是肯定的。假如没有定义这个转换,编译器在处理uint转Currency的时候,就会通过float来中转。而提供一种直接转换的方式,显然更加的效率,由于它对于性能上有所提升,因此你可以在确认它和float定义不冲突的情况下,为编译器提供这种直接转换方式。在另外的一些情况中,你可能会发现分别为不同预定义类型提供的强制转换,更多的是隐式的而非显式,只不过跟这儿我们提到的内容没有关系。
更好地测试你提供的强制转换是否互相兼容的方式是,不管用哪种方式转换,看看它们是否返回相同的值(与float转int的那种精度丢失不同)。考虑下面这样的代码:
var balance = new Currency(50, 35);
ulong bal = (ulong) balance;
早先,只存在一条路径实现这个转换:就是先把Currency隐式地转成float,然后再从float显式地转换成ulong。float转ulong需要显式声明,确实这里你也显式地给出了声明,因此上面的例子能正常执行。
现在,让我们假定你添加了一个新的类型转换,允许隐式地将Currency转成uint,包括前面提供uint转Currency,它们的代码如下:
public static implicit operator Currency (uint value) => new Currency(value, 0);
public static implicit operator uint (Currency value) => value.Dollars;
现在编译器有另外一种可能的路由来将Currency转换成ulong:先将Currency隐式地转成uint,然后再从uint隐式地转换成ulong。那么这俩种方式编译器会选择哪一个?当存在多种可能的选择的时候,编译器会选择何种方式C#有着明确的规则。(本书并未详细介绍此规则,如果你对细节感兴趣,你可以查阅MSDN文档)。只要你确保你提供的所有路径的类型转换返回的是同样的答案即可(除了精度损失以外的情况),至于编译器会选择哪条路径则不用我们考虑。事实上在上面的这种情况下,编译器会选择Currency-uint-ulong而非Currency-float-ulong。
为了更好的测试Currency转uint,我们编写如下的代码:
static void Main()
{
try
{
var balance = new Currency(50,35);
Console.WriteLine(balance);
Console.WriteLine($"balance is {balance}");
uint balance3 = (uint) balance;
Console.WriteLine($"Converting to uint gives {balance3}");
}
catch (Exception ex)
{
Console.WriteLine($"Exception occurred: {e.Message}");
}
}
执行代码,结果如下所示:
50
balance is $50.35
Converting to uint gives 50
从输出的第三行里可以看到,转换成uint的部分已经成功生效,虽然就跟预期的一样,丢失了所有的小数部分。尝试将负数的float转换成Currency同样会抛出溢出异常因为float-Currency内部代码定义了checked上下文。
然而,输出的第一行并未正确地显示balance的值,它输出的是50而非50.35。
所以,这中间究竟发生了什么?这里的问题在于,当你将类型转换应用于一些带有多个重载的方法的时候,结果是不可预知的。
第二行WriteLine语句中使用了字符串的格式化方式,它隐式地调用了Currency.ToString方法,来确保Currency是按照字符串进行显示的。
而第一行WriteLine语句,则非如此,它只是将Currency结构体当成一个参数传递给WriteLine方法。现在,WriteLine方法拥有很多重载,但没有一个重载方法直接适用于Currency。因此,编译器尝试翻来覆去地查看它是否可以将Currency转换成某种类型以便它能匹配WriteLine方法的某个版本。而当它找到有一个接收uint类型参数的WriteLine方法可以快速高效地显示uint数的时候,它隐式地将Currency转换成了uint,然后调用了WriteLine(uint parameter)方法——结果如你所见,显示是50。
事实上,WriteLine也存在另外一个重载方法,接收double类型的参数并进行输出。假如你并未为Currency提供到uint的类型转换的话,你将会发现第一行代码将Currency以double类型进行输出。在这种情况下,并非直接将Currency转成uint,而是通过Currency-float-double方式进行转换,以便匹配WriteLine的重载方法。然而,既然存在一个直接转uint的路径,编译器优先选择以这种方式进行输出。
上面的结论是,当你调用某个拥有多种重载版本的方法时,假如你传递的参数和所有版本的方法都不是精确匹配的话,实际上你要求编译器不但只处理参数类型的转换,还要求编译器选择使用何种版本的重载方法。编译器总是按照既定逻辑和严格的规则进行运行的,只不过结果可能并非像你预期的那样。假如存在任何有争议的情况,你最好还是显式地指定你想应用何种类型转换。
6.10 小结
本章主要着眼于C#提供的标准运算符,描述了对象相等性之间的机制,并且考察了编译器是如何在标准数据类型之间进行转换的。我们也为你演示了如何通过运算符重载,来为你自己定义的类型,实现各种运算。最后,你学习了一种特殊的重载运算符,强制类型转换运算符(cast),它让你可以指定你自己的类型如何转换成另外一种数据类型。
下一章我们将深入了解数组,其中索引运算符将扮演一个重要的角色。