.NET面試題系列[4] - C# 基礎知識(2)


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
View Code

如果去掉(byte),改為隱式執行,則會無法通過編譯。可以利用checked關鍵字檢查是否有溢出的情況。

1             checked
2             {
3                 byte b = (byte)a;             //Overflow
4                 Console.WriteLine(a + 1);     //Overflow
5                 Console.WriteLine(b);
6             }
View Code

也可以使用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     }
View Code

回答下面每一行代碼是可以執行,還是造成編譯時錯誤,或運行時錯誤:

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; //裝箱
View Code

執行完第一句后,托管堆沒有任何東西,棧上有一個整形變量。第二句就是裝箱。因為object是一個引用類型,它必須指向堆上的某個對象,而x是值類型,沒有堆上的對應對象。所以需要使用裝箱,在堆上創造一個x。裝箱包括了以下的步驟:

  1. 分配內存。這個例子中需要一個整形變量,加上托管堆上所有的對象都有的兩個額外成員(類型對象指針和同步塊索引)那么多的內存。類型對象指針指向int類型對象。
  2. 值類型的變量復制到新分配的堆內存。
  3. 返回對象的地址。

注意,不需要初始化int的類型對象,因為其在執行程序之前,編譯之后,就已經被CLR初始化了。

拆箱的過程

拆箱並不是把裝箱的過程倒過來,拆箱的代價比裝箱低得多。拆箱不需要額外分配內存。

1             int i = 1;            
2             object o = i;
3             var j = (byte) o;
View Code

拆箱包括了以下的步驟:

  1. 如果已裝箱實例為null,拋出NullReference異常
  2. 如果對象不是null但類型不是原先未裝箱的值類型,則拋出InvalidCast異常,比如上面的代碼
  3. 獲取已裝箱實例中值類型字段的地址
  4. 創建一個新的值類型變量,其值使用第三步獲取到的值(復制)

通常避免無謂的裝箱和拆箱,可以通過使用泛型,令對象成為強類型,從而也就沒有了轉換類型的可能。也可以通過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 }
View Code

由於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 }
View Code

 

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     }
View Code

 

3.1 字符串和普通的引用類型相比有什么特別的地方嗎?

字符串的行為很像值類型:

  1. 字符串使用等於號互相比較時,比較的是字符串的值而不是是否指向同一個引用,這和引用類型的比較不同,而和值類型的比較相同。
  2. 字符串雖然是引用類型,但如果在某方法中,將字符串傳入另一方法,在另一方法內部修改,執行完之后,字符串的值並不會改變,而引用類型無論是按值傳遞還是引用傳遞,值都會發生變化。

3.2 關於StringBuilder的性能問題

我們考慮將N個字符串連接起來的場景。在N極少時(小於8左右),StringBuilder的性能並不一定優於簡單的使用+運算符。所以此時,我們不需要使用StringBuilder。

當N很大(例如超過100)時,StringBuilder的效能大大優於使用+運算符。

當N很大,但你知道N的確定數值時,考慮使用String.Concat方法。這個方法的速度之所以快,主要有以下原因:

  1. 當N確定,每個字符串也確定時,最終的字符串長度就確定了。此時,可以一次性為其分配這么大塊的內存。而StringBuilder如果沒有指明初始長度,或指定了一個較小的長度,則會不斷擴容,消耗資源。擴容的動作分為如下幾步:在內存中分配一個更大的空間,然后將現有的字符串復制過去(還余下一些空位for further use)
  2. 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 }
View Code

而TryParse不會引發異常,它會返回一個bool值提示轉換是否成功:

1 int quantity;
2 if (int.TryParse(txtQuantity.Text, out quantity) == false)
3 {
4     quantity = 0;
5 }
View Code

代碼變得十分簡單易懂。當然,直接使用顯式轉換也是一種方法。顯式轉換和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管轄,所以要到程序結束才會釋放。

 


免責聲明!

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



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