C#高級編程第11版 - 第六章


導航

第六章 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的實例之間進行轉換。你需要注意的是這之間有不少限制條件:

  • 假如一個類派生自另外一個類,你無法在這兩者之間再次定義強制轉換(因為它們之間的轉換已經實現了)。
  • 強制轉換必須定義在互相轉換的類型中,定義在它們倆中哪一個都行,但不能定義在其他第三者的類型中。

為了演示上面這些要求,我們假定你擁有以下結構關系的類:

Cast示例

換句話說,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),它讓你可以指定你自己的類型如何轉換成另外一種數據類型。

下一章我們將深入了解數組,其中索引運算符將扮演一個重要的角色。

擴展


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM