2 類型轉換
面試出現頻率:主要考察裝箱和拆箱。對於有筆試題的場合也可能會考一些基本的類型轉換是否合法。
重要程度:10/10
CLR最重要的特性之一就是類型安全性。在運行時,CLR總是知道一個對象是什么類型。對於基元類型之間的相互轉換,可以顯式或者隱式執行,例如將一個int轉換為long。但如果將精度較大的類型轉化為精度較小的類型,必須顯式執行,且可能會丟失精度,但不會發生異常。可以利用checked關鍵字強制擲出OverflowException異常。
CLR允許將一個對象轉化為它的任何基類型。C#不要求任何特殊語法即可將一個對象轉換為它的任何基類型。然而,將對象轉換為它的某個派生類型時,C#要求開發人員只能進行顯式轉換,因為這樣的轉換可能在運行時失敗。
2.1 基元類型的類型轉換
對基元類型進行轉換時,可以顯式或者隱式執行。如果遇到丟失精度的情況,C#將會向下取整(即無論如何都是舍去)。例如,對int的最大值轉換為byte,將會得到255。對一個小數位精度較高的數轉化為小數位精度較低的數,則簡單的舍去多余的小數位。

1 int a = int.MaxValue; 2 Console.WriteLine(a); 3 byte b = (byte) a; //255
如果去掉(byte),改為隱式執行,則會無法通過編譯。可以利用checked關鍵字檢查是否有溢出的情況。

1 checked 2 { 3 byte b = (byte)a; //Overflow 4 Console.WriteLine(a + 1); //Overflow 5 Console.WriteLine(b); 6 }
也可以使用unchecked關鍵字忽略所有的精度和溢出檢查。但由於這就是編譯器的默認行為,所以unchecked關鍵字很少用到。
2.2 引用類型之間的類型轉換
可以將一個對象轉化為它的任何基類型。轉換時,將等號右邊的和左邊的類型進行比較。如果左邊的是基類,則安全,否則發生編譯時異常,必須進行顯式轉換。例如object a = new Manager可以讀為:Manager是一個object,所以這個(隱式)轉換是安全的。但反過來就錯誤。顯式轉換永遠發生運行時而不是編譯時異常。
例如下面的測試題,假定有如下的定義:

1 public class B 2 { 3 4 } 5 6 public class D : B 7 { 8 9 }
回答下面每一行代碼是可以執行,還是造成編譯時錯誤,或運行時錯誤:
Object o1 = new Object();
可以執行。
Object o2 = new B();
可以執行。這將會在棧上新建一個名為o2的對象,類型為Object。他指向堆上的B類型對象。因為Object類型是B的基類,所以類型安全。但由於o2的類型是Object,o2將只擁有Object的那幾個方法(你可以自行在IDE中試驗一下)。如果你執行Console.WriteLine(o2.GetType()),你會得到[命名空間名稱].B,也就是說,GetType返回指向的類型對象的具體類型名稱。
Object o3 = new D();
可以執行,原因同上。
Object o4 = o3;
可以執行,可以將其看成Object o4 = new D();
在執行完上面四句話之后,內存中的狀況如圖:
如果你執行Console.WriteLine(object.ReferenceEquals(o3, o4)),會得到true的返回值,因為它們指向同一個實例。我們繼續往下看:
B b1 = new B();
可以執行。
B b2 = new D();
可以執行。原因同第二個。
D d1 = new D();
可以執行。
B b3 = new Object();
編譯時錯誤。不能將Object類型轉為B。
D d2 = new Object();
編譯時錯誤。原因同上。在執行完上面所有語句之后,內存中的狀況如圖(省略了類型對象指針):
B b4 = d1;
可以執行因為左邊的B是基類,d1是派生類D。
D d3 = b2;
編譯時錯誤。左邊的是派生類,而b2的類型是B(在棧上的類型)。
D d4 = (D) d1;
可以執行。因為d1也是D類型,故沒有發生實際轉換。在執行完上面所有語句之后,內存中的狀況如圖(省略了類型對象指針):
D d6 = (D) b1;
運行時錯誤。在顯式轉換中,b1的類型是B,不能轉換為其派生類D。通過顯式轉換永遠不會發生編譯時錯誤。
B b5 = (B) o1;
運行時錯誤。在顯式轉換中,o1的類型是基類Object,不能轉換為其派生類B。
2.3 什么是拆箱和裝箱?它們對性能的損耗體現在何處?
拆箱與裝箱就是值類型與引用類型的轉換,其是值類型和引用類型之間的橋梁。之所以可以這樣轉換是因為C#所有類型都源自Object(所有值類型都源於ValueType,而ValueType源於Object)。通過深入了解拆箱和裝箱的過程,我們可以知道其包含了對堆上內存的操作,故其會消耗性能,因為這是完全不必要的。當了解了新建對象時內存的活動之后,裝箱的內存活動就可以很容易的推斷出來。
裝箱的過程
對於簡單的例子來說:

1 int x = 1023; 2 object o = x; //裝箱
執行完第一句后,托管堆沒有任何東西,棧上有一個整形變量。第二句就是裝箱。因為object是一個引用類型,它必須指向堆上的某個對象,而x是值類型,沒有堆上的對應對象。所以需要使用裝箱,在堆上創造一個x。裝箱包括了以下的步驟:
- 分配內存。這個例子中需要一個整形變量,加上托管堆上所有的對象都有的兩個額外成員(類型對象指針和同步塊索引)那么多的內存。類型對象指針指向int類型對象。
- 值類型的變量復制到新分配的堆內存。
- 返回對象的地址。
注意,不需要初始化int的類型對象,因為其在執行程序之前,編譯之后,就已經被CLR初始化了。
拆箱的過程
拆箱並不是把裝箱的過程倒過來,拆箱的代價比裝箱低得多。拆箱不需要額外分配內存。

1 int i = 1; 2 object o = i; 3 var j = (byte) o;
拆箱包括了以下的步驟:
- 如果已裝箱實例為null,拋出NullReference異常
- 如果對象不是null但類型不是原先未裝箱的值類型,則拋出InvalidCast異常,比如上面的代碼
- 獲取已裝箱實例中值類型字段的地址
- 創建一個新的值類型變量,其值使用第三步獲取到的值(復制)
通常避免無謂的裝箱和拆箱,可以通過使用泛型,令對象成為強類型,從而也就沒有了轉換類型的可能。也可以通過IL工具,觀察代碼的IL形式,檢查是否有關鍵字box和unbox。
2.4 使用is或as關鍵字進行類型轉換
可以使用is或as關鍵字進行類型轉換。
is將檢測一個對象是否兼容於指定的類型,並返回一個bool。它永遠不會拋出異常。如果轉型對象是null,就返回false。典型的應用is進行類型轉換的方式為:

1 object o = new object(); 2 class A 3 { 4 5 } 6 7 if (o is A) //執行第一次類型兼容檢查 8 { 9 A a = (A) o; //執行第二次類型兼容檢查 10 }
由於is實際上會造成兩次類型兼容檢查,這是不必要的。as關鍵字在一定程度上,可以改善性能。as永遠不會拋出異常,如果轉型對象是null,就返回null。典型的應用as進行類型轉換的方式為:

1 object o = new object(); 2 class B 3 { 4 } 5 B b = o as B; //執行一次類型兼容檢查 6 if (b != null) 7 { 8 MessageBox.Show("b is B's instance."); 9 }
3. 字符串
面試出現頻率:基本上肯定出現。特別是對字符串相加的性能問題的考察(因為也沒有什么其他好問的)。如果你指出StringBuilder是一個解決方案,並強調一定要為其設置一個初始容量,面試官將會很高興。
重要程度:10/10。
字符串是引用類型。可以通過字符串的默認值為null來記憶這點。string是基元類型String在c#中的別名,故這兩者沒有任何區別。
注意字符串在修改時,是在堆上創建一個新的對象,然后將棧上的字符串指向新的對象(舊的對象變為垃圾等待GC回收)。字符串的值是無法被修改的(具有不變性)。考慮使用StringBuilder來防止建立過多對象,減輕GC壓力。
字符串的==操作和.Equal是相同的,因為==已經被重寫為比較字符串的值而不是其引用。作為引用類型,==本來是比較引用的,但此時被重寫,這也是字符串看起來像值類型的一個原因。
當使用StringBuilder時,如果你大概知道要操作的字符串的長度范圍,請指明它的初始長度。這可以避免StringBuilder初始化時不斷擴容導致的資源消耗。
你經常會有機會擴展這個類,例如為這個類擴展一個顛倒的字符串方法:

1 public static string Reverse(string s) 2 { 3 char[] charArray = s.ToCharArray(); 4 Array.Reverse(charArray); 5 return new string(charArray); 6 }
3.1 字符串和普通的引用類型相比有什么特別的地方嗎?
字符串的行為很像值類型:
- 字符串使用等於號互相比較時,比較的是字符串的值而不是是否指向同一個引用,這和引用類型的比較不同,而和值類型的比較相同。
- 字符串雖然是引用類型,但如果在某方法中,將字符串傳入另一方法,在另一方法內部修改,執行完之后,字符串的值並不會改變,而引用類型無論是按值傳遞還是引用傳遞,值都會發生變化。
3.2 關於StringBuilder的性能問題
我們考慮將N個字符串連接起來的場景。在N極少時(小於8左右),StringBuilder的性能並不一定優於簡單的使用+運算符。所以此時,我們不需要使用StringBuilder。
當N很大(例如超過100)時,StringBuilder的效能大大優於使用+運算符。
當N很大,但你知道N的確定數值時,考慮使用String.Concat方法。這個方法的速度之所以快,主要有以下原因:
- 當N確定,每個字符串也確定時,最終的字符串長度就確定了。此時,可以一次性為其分配這么大塊的內存。而StringBuilder如果沒有指明初始長度,或指定了一個較小的長度,則會不斷擴容,消耗資源。擴容的動作分為如下幾步:在內存中分配一個更大的空間,然后將現有的字符串復制過去(還余下一些空位for further use)
- StringBuilder有線程安全的考慮,故會拖慢一點時間
不過,如果你可以確定最終字符串長度的值,並將其作為初始長度分配給StringBuilder,則StringBuilder將不需要擴容,其性能將與String.Concat方法幾乎相同(由於還有性能安全的考慮,故會稍微慢一點點)。
參考:
http://blog.zhaojie.me/2009/11/string-concat-perf-1-benchmark.html
http://blog.zhaojie.me/2009/12/string-concat-perf-2-stringbuilder-implementations.html
http://blog.zhaojie.me/2009/12/string-concat-perf-3-profiling-analysis.html
3.3 什么是字符串的不變性?
字符串的不變性指的是字符串一經賦值,其值就不能被更改。當使用代碼將字符串變量等於一個新的值時,堆上會出現一個新的字符串,然后棧上的變量指向該新字符串。沒有任何辦法更改原來字符串的值。
3.4 字符串轉換為值類型
有時我們不得不處理這樣的情況,例如從WPF應用的某個文本框中獲得一個值,並將其轉換為整數。以int為例,其提供了兩個靜態方法Parse和TryParse。當轉換失敗時,Parse會擲出異常,使用Parse的異常處理比較麻煩:

1 int quantity; 2 try 3 { 4 quantity = int.Parse(txtQuantity.Text); 5 } 6 catch (FormatException) 7 { 8 quantity = 0; 9 } 10 catch (OverflowException) 11 { 12 quantity = 0; 13 }
而TryParse不會引發異常,它會返回一個bool值提示轉換是否成功:

1 int quantity; 2 if (int.TryParse(txtQuantity.Text, out quantity) == false) 3 { 4 quantity = 0; 5 }
代碼變得十分簡單易懂。當然,直接使用顯式轉換也是一種方法。顯式轉換和TryParse並沒有顯著的性能區別。
3.5 字符串的駐留(interning)
從來沒有人問過我關於這方面的問題,我也是不久之前才學到的。簡單來說,字符串駐留是CLR的JIT做代碼優化時,送給我們的一個小禮物。CLR會維護一個字符串駐留池(內部哈希表),並在新建字符串時,探查是否已經有相同值的字符串存在。只有以下兩種情況才會自動探查。
1. 如果編譯器發現已經有相同值的字符串存在,則不新建字符串(在堆上),而是讓新舊兩字符串變量在棧上指向同一個堆上的字符串值。如果沒有則在駐留池中增加一個新的成員。
var s1 = "123"; var s2 = "123"; Console.WriteLine(System.Object.Equals(s1, s2)); //輸出 True Console.WriteLine(System.Object.ReferenceEquals(s1, s2)); //輸出 True
這意味着,堆上只有一條字符串“123”(隱式駐留)。如果我們預先知道許多字符串對象都可能有相同的值,就可以利用這點來提高性能。字符串的駐留的另一個體現方式是常量字符串相加的優化。下面例子輸出結果也是兩個True:
string st1 = "123" + "abc"; string st2 = "123abc"; Console.WriteLine(st1 == st2); Console.WriteLine(System.Object.ReferenceEquals(st1, st2));
堆上的字符串只有一個 ----“123abc”。下面例子則稍有不同:
string s1 = "123"; string s2 = s1 + "abc"; string s3 = "123abc"; Console.WriteLine(s2 == s3); Console.WriteLine(System.Object.ReferenceEquals(s2, s3));
第二個布爾值為False,因為變量和常量相加的動作不會被編譯器優化。
並非每次新建字符串,或者通過某種方式生成了一條新的字符串時,其都會被駐留。例如,上面例子中,變量字符串和常量字符串相加,就沒有觸發駐留行為,同理ToString,ToUpper等方法也不會(只有上面兩種情況才會)。我們也可以通過訪問駐留池來顯式留用字符串。我們可以使用方法string.Intern為駐留池新增一個字符串,或者使用方法IsInterned探查字符串是否已經被駐留。
因為變量字符串和常量字符串相加無法利用駐留行為,所以無論我們怎么改進,上面的最后一行總是會輸出False。例如:
string s1 = "123"; String.Intern("123abc"); string s2 = s1 + "abc"; string s3 = "123abc"; Console.WriteLine(s2 == s3); Console.WriteLine(System.Object.ReferenceEquals(s2, s3));
此時s2的創建根本不會搭理駐留池。同理,這樣也不行:
string s1 = "123"; String.Intern("123"); string s2 = 123.ToString(); Console.WriteLine(System.Object.ReferenceEquals(s2, s1));
通常來說,字符串駐留只有在常量字符串的分配和相加時才有意義。而且,我們要注意到字符串駐留的一個負面影響:駐留池的內存不受GC管轄,所以要到程序結束才會釋放。