第2章
資料類型

以第1章的HelloWorld程式為基礎,你對C#語言、它的結構、基本文法以及如何編寫最簡單的程式有了初步了解。本章讨論基本C#類型,繼續鞏固C#的基礎知識。
本書到目前為止隻用過少量内建資料類型,而且隻是一筆帶過。C#有大量類型,而且可合并類型來建立新類型。但C#有幾種類型非常簡單,是其他所有類型的基礎,它們稱為預定義類型(predefined type)或基元類型(primitive type)。C#語言的基元類型包括八種整數類型、兩種用于科學計算的二進制浮點類型、一種用于金融計算的十進制浮點類型、一種布爾類型以及一種字元類型。本章将探讨這些基中繼資料類型,并更深入地研究string類型。
2.1 基本數值類型
C#基本數值類型都有關鍵字與之關聯,包括整數類型、浮點類型以及decimal類型。decimal是特殊的浮點類型,能存儲大數字而無表示錯誤。
2.1.1 整數類型
C#有八種整型,可選擇最恰當的一種來存儲資料以避免浪費資源。表2.1總結了每種整型。
表2.1(以及表2.2和表2.3)專門有一列給出了每種類型的完整名稱,本章稍後會講述字尾問題。C#所有基元類型都有短名稱和完整名稱。完整名稱對應BCL(基類庫)中的類型名稱。該名稱在所有語言中都相同,對程式集中的類型進行了唯一性辨別。由于基中繼資料類型是其他類型的基礎,是以C#為基中繼資料類型的完整名稱提供了短名稱(或稱為縮寫)。其實從編譯器的角度看,兩種名稱完全一樣,最終都生成相同的代碼。事實上,檢查最終生成的CIL代碼,根本看不出源代碼具體使用的名稱。
C#支援完整BCL名稱和關鍵字,造成開發人員對在什麼時候用什麼犯難。不要時而用這個,時而用那個,最好堅持用一種。C#開發人員一般用C#關鍵字。例如,用int而不是System.Int32,用string而不是System.String(甚至不要用String這種簡化形式)。
堅持一緻可能和其他設計規範沖突。例如,雖然規範說要用C#關鍵字取代BCL名稱,但有時需維護公司遺留下來的風格相反的檔案(或檔案庫)。這時隻能維持原風格,而不是強行引入新風格,造成和原來的約定不一緻。但話又說回來,如原有“風格”實際是不好的編碼實踐,有可能造成bug,嚴重妨礙維護,還是應盡量全盤糾正問題。
2.1.2 浮點類型(float和double)
浮點數精度可變。除非用分數表示時,分母恰好是2的整數次幂,否則用二進制浮點類型無法準确表示該數。将浮點變量設為0.1,很容易表示成0.099 999 999 999 999 999或者0.100 000 000 000 000 000 1(或者其他非常接近0.1的數)。另外,像阿伏伽德羅常數這樣非常大的數字(6.02×1023),即使誤差為108,結果仍然非常接近6.02×1023,因為原始數字實在是太大了。根據定義,浮點數的精度與它所代表的數字的大小成正比。準确地說,浮點數精度由有效數位的個數決定,而不是由一個固定值(比如±0.01)決定。
C#支援表2.2所示的兩個浮點數類型。
為了友善了解,二進制數被轉換成十進制數。如表2.2所示,二進制數位被轉換成15個十進制數位,餘數構成第16個十進制數位。具體地說,1.7×10307~1×10308的數隻有15個有效數位。但1×10308~1.7×10308的數有16個。decimal類型的有效數位範圍與此相似。
2.1.3 decimal類型
C#還提供了128位精度的十進制浮點類型(參見表2.3)。它适合大而精确的計算,尤其是金融計算。
和浮點數不同,decimal類型保證範圍内的所有十進制數都是精确的。是以,對于decimal類型來說,0.1就是0.1,而不是近似值。不過,雖然decimal類型具有比浮點類型更高的精度,但它的範圍較小。是以,從浮點類型轉換為decimal類型可能發生溢出錯誤。此外,decimal的計算速度稍慢(雖然差别不大以至于完全可以忽略)。
2.1.4 字面值
字面值(literal value)表示源代碼中的固定值。例如,假定希望用System.Console. WriteLine()輸出整數值42和double值1.618 034(黃金分割比例),可以使用如代碼清單2.1所示的代碼。
輸出2.1展示了代碼清單2.1的結果。
預設情況下,輸入帶小數點的字面值,編譯器自動把它解釋成double類型。相反,整數值(沒有小數點)通常預設為int,前提是該值不是太大,以至于無法用int來存儲。如果值太大,編譯器會把它解釋成long。此外,C#編譯器允許向非int的數值類型指派,前提是字面值對于目标資料類型來說合法。例如,short s = 42和byte b = 77都是允許的。但這一點僅對字面值成立。不使用額外的文法,b = s就是非法的,具體參見2.6節。
前面說過C#有許多數值類型。在代碼清單2.2中,一個字面值被直接放到C#代碼中。由于帶小數點的值預設為double類型,是以如輸出2.2所示,結果是1.61803398874989(最後一個數字5丢失了),這符合我們預期的double值的精度。
輸出2.2
要顯示具有完整精度的數字,必須将字面值顯式聲明為decimal類型,這是通過追加一個M(或者m)來實作的,如代碼清單2.3和輸出2.3所示。
輸出2.3
代碼清單2.3的輸出符合預期:1.618033988749895。注意d表示double,之是以用m表示decimal,是因為這種資料類型經常用于貨币(monetary)計算。
還可以使用F和D作為字尾,将字面值分别顯式聲明為float或者double。對于整數資料類型,相應字尾是U、L、LU和UL。整數字面值的類型是像下面這樣确定的:
- 無字尾的數值字面值按以下順序解析成能存儲該值的第一個資料類型:int,uint,long,ulong。
- 字尾U的數值字面值按以下順序解析成能存儲該值的第一個資料類型:uint,ulong。
- 字尾L的數值字面值按以下順序解析成能存儲該值的第一個資料類型:long,ulong。
- 如字尾是UL或LU,就解析成ulong類型。
注意字面值的字尾不區分大小寫。但一般推薦大寫,避免出現小寫字母l和數字1不好區分的情況。
有時數字很大,很難辨認。為解決可讀性問題,C# 7.0新增了對數字分隔符的支援。如代碼清單2.4所示,可在書寫數值字面值的時候用下劃線(_)分隔。
本例将數字轉換成千分位,但隻是為了好看,C#不要求這樣。可在數字第一位和最後一位之間的任何位置添加分隔符。事實上,還可以連寫多個下劃線。
有時可考慮使用指數記數法,避免在小數點前後寫許多個0。指數記數法要求使用e或E中綴,在中綴字母後面添加正整數或者負整數,并在字面值最後添加恰當的資料類型字尾。例如,可将阿伏伽德羅常數作為float輸出,如代碼清單2.5和輸出2.4所示。
輸出2.4
前面讨論數值字面值的時候隻使用了十進制值。C#還允許指定十六進制值。為值附加0x字首,再添加希望使用的十六進制數字,如代碼清單2.6所示。
輸出2.5展示了結果。
輸出2.5
注意,代碼輸出的仍然是42,而不是0x002A。
從C# 7.0起可将數字表示成二進制值,如代碼清單2.7所示。
文法和十六進制文法相似,隻是使用0b字首(允許大寫B)。參考第4章的初學者主題“位和位元組”了解二進制記數法以及二進制和十進制之間的轉換。注意從C# 7.2起,數字分隔符可以放到代表十六進制的x或者代表二進制的b後面(稱為前導數字分隔符)。
2.2 更多基本類型
迄今為止隻讨論了基本數值類型。C#還包括其他一些類型:bool、char和string。
2.2.1 布爾類型(bool)
另一個C#基元類型是布爾(Boolean)或條件類型bool。它在條件語句和表達式中表示真或假。允許的值包括關鍵字true和false。bool的BCL名稱是System.Boolean。例如,為了在不區分大小寫的前提下比較兩個字元串,可以調用string.Compare()方法并傳遞bool字面值true,如代碼清單2.10所示。
本例在不區分大小寫的前提下比較變量option的内容和字面值/Help,結果賦給comparison。
雖然理論上一個二進制位足以容納一個布爾類型的值,但bool實際大小是一個位元組。
2.2.2 字元類型(char)
字元類型char表示16位字元,取值範圍對應于Unicode字元集。從技術上說,char的大小和16位無符号整數(ushort)相同,後者取值範圍是0~65 535。但char是C#的特有類型,在代碼中要單獨對待。
char的BCL名稱是System.Char。
輸入char字面值需要将字元放到一對單引号中,比如'A'。所有鍵盤字元都可這樣輸入,包括字母、數字以及特殊符号。
有的字元不能直接插入源代碼,需進行特殊處理。首先輸入反斜杠()字首,再跟随一個特殊字元代碼。反斜杠和特殊字元代碼統稱為轉義序列(escape sequence)。例如,n代表換行符,而t代表制表符。由于反斜杠标志轉義序列開始,是以要用\表示反斜杠字元。
代碼清單2.11輸出用'表示的一個單引号。
表2.4總結了轉義序列以及字元的Unicode編碼。
可用Unicode編碼表示任何字元。為此,請為Unicode值附加u字首。可用十六進制記數法表示Unicode字元。例如,字母A的十六進制值是0x41,代碼清單2.12使用Unicode字元顯示笑臉符号(:)),輸出2.8展示了結果。
輸出2.8
2.2.3 字元串
零或多個字元的有限序列稱為字元串。C#的基本字元串類型是string,BCL名稱是System.String。對于已熟悉了其他語言的開發者,string的一些特點或許會出乎預料。除了第1章讨論的字元串字面值格式,還允許使用逐字字首@,允許用$字首進行字元串插值。最後,string是一種“不可變”類型。
- 字面值
為了将字面值字元串輸入代碼,要将文本放入雙引号(")内,就像HelloWorld程式中那樣。字元串由字元構成,是以轉義序列可嵌入字元串内。
例如,代碼清單2.13顯示兩行文本。但這裡沒有使用System.Console.WriteLine(),而是使用System.Console.Write()來輸出換行符n。輸出2.9展示了結果。
輸出2.9
雙引号要用轉義序列輸出,否則會被用于定義字元串開始與結束。
C#允許在字元串前使用@符号,指明轉義序列不被處理。結果是一個逐字字元串字面值(verbatim string literal),它不僅将反斜杠當作普通字元,還會逐字解釋所有空白字元。例如,代碼清單2.14的三角形會在控制台上原樣輸出,其中包括反斜杠、換行符和縮進。輸出2.10展示了結果。
不使用@字元,這些代碼甚至無法通過編譯。事實上,即便将形狀變成正方形,避免使用反斜杠,代碼仍然不能通過編譯,因為不能将換行符直接插入不以@符号開頭的字元串中。
輸出2.10
以@開頭的字元串唯一支援的轉義序列是"",代表一個雙引号,不會終止字元串。
假如同一字元串字面值在程式集中多次出現,編譯器在程式集中隻定義字元串一次,且所有變量都指向它。這樣一來,假如在代碼中多處插入包含大量字元的同一個字元串字面值,最終的程式集隻反映其中一個的大小。
- 字元串插值
如第1章所述,從C# 6.0起,字元串可用插值技術嵌入表達式。文法是在字元串前添加$符号,并在字元串中用一對大括号嵌入表達式。例如:
其中,firstName和lastName是引用了變量的簡單表達式。注意逐字和插值可組合使用,但要先指定$,再指定@,例如:
由于是逐字字元串,是以按字元串的樣子分兩行輸出。在大括号中換行則起不到換行效果:
上述代碼在一行中輸出字元串内容。注意此時仍需@符号,否則無法編譯。
- 字元串方法
和System.Console類型相似,string類型也提供了幾個方法來格式化、連接配接和比較字元串。
表2.5中的Format()方法具有與Console.Write()和Console.WriteLine()方法相似的行為。差別在于,string.Format()不是在控制台視窗中顯示結果,而是傳回結果。當然,有了字元串插值後,用到string.Format()的機會減少了很多(本地化時還是用得着)。但在幕後,字元串插值編譯成CIL後都會使用string.Format()。
表2.5列出的都是靜态方法。這意味着為了調用方法,需在方法名(例如concate)之前附加方法所在類型的名稱(例如string)。但string類還有一些執行個體方法。執行個體方法不以類型名作為字首,而是以變量名(或者對執行個體的其他引用)作為字首。表2.6列出了部分執行個體方法和例子。
- 字元串格式化
無論使用string.Format()還是C# 6.0字元串插值來構造複雜格式的字元串,都可通過一組覆寫面廣和複雜的格式化模式來顯示數字、日期、時間、時間段等。例如,給定decimal類型的price變量,則string.Format("{0,20:C2}", price)或等價的插值字元串$"{price,20:C2}"都使用預設的貨币格式化規則将decimal值轉換成字元串。即添加本地貨币符号,小數點後四舍五入保留兩位,整個字元串在20個字元的寬度内右對齊(要左對齊就為20添加負号。另外,寬度不夠隻好超出)。因篇幅有限,無法詳細讨論所有可能的格式字元串,請在MSDN文檔中查閱string.Format()擷取格式字元串的完整清單。
要在插值或格式化的字元串中添加實際的左右大括号,可連寫兩個大括号來表示。例如,插值字元串$"{{ {price:C2} }}"可生成字元串"{ $1,234.56 }"。
- 換行符
輸出換行所需的字元由作業系統決定。Microsoft Windows的換行符是r和n這兩個字元的組合,UNIX則是單個n。為消除平台之間的不一緻,一個辦法是使用System.Console.WriteLine()自動輸出空行。為確定跨平台相容性,可用System.Environment.NewLine代表換行符。換言之,System.Console.WriteLine("Hello World")和System.Console.Write("Hello World" + System.Environment.NewLine)等價。注意在Windows上,System.WriteLine()和System.Console.Write(System.Environment.NewLine)等價于System.Console.Write("rn")而非System.Console.Write("n")。總之,要依賴System.WriteLine()和System.Environment.NewLine而不是n來確定跨平台相容。
- 字元串長度
判斷字元串長度可以使用string的Length成員。該成員是隻讀屬性。不能設定,調用時也不需要任何參數。代碼清單2.16示範了如何使用Length屬性,輸出2.11是結果。
輸出2.11
字元串長度不能直接設定,它是根據字元串中的字元數計算得到的。此外,字元串長度不能更改,因為字元串不可變。
- 字元串不可變
string類型的一個關鍵特征是它不可變(immutable)。可為string變量賦一個全新的值,但出于性能考慮,沒有提供修改現有字元串内容的機制。是以,不可能在同一個記憶體位置将字元串中的字母全部轉換為大寫。隻能在其他記憶體位置建立字元串,讓它成為舊字元串大寫字母版本,舊字元串在這個過程中不會被修改,如果沒人引用它,會被垃圾回收。代碼清單2.17展示了一個例子。
輸出2.12展示了結果。
輸出2.12
從表面上看,text.ToUpper()似乎應該将text中的字元轉換成大寫。但由于string類型不可變,是以text.ToUpper()不會進行這樣的修改。相反,text.ToUpper()會傳回新字元串,它需要儲存到變量中,或直接傳給System.Console.WriteLine()。代碼清單2.18給出了糾正後的代碼,輸出2.13是結果。
輸出2.13
如忘記字元串不可變的特點,很容易會在使用其他字元串方法時犯下和代碼清單2.17相似的錯誤。
要真正更改text中的值,将ToUpper()的傳回值賦回給text即可。如下例所示:
- System.Text.StringBuilder
如有大量字元串需要修改,比如要經曆多個步驟來構造一個長字元串,可考慮使用System.Text.StringBuilder類型而不是string。StringBuilder包含Append()、AppendFormat()、Insert()、Remove()和Replace()等方法。雖然string也提供了其中一些方法,但兩者關鍵的差別在于,在StringBuilder上,這些方法會修改StringBuilder本身中的資料,而不是傳回新字元串。
2.3 null和void
與類型有關的另外兩個關鍵字是null和void。null值表明變量不引用任何有效的對象。void表示無類型,或者沒有任何值。
2.3.1 null
null可直接賦給字元串變量,表明變量為“空”,不指向任何位置。隻能将null賦給引用類型、指針類型和可空值類型。目前隻講了string這一種引用類型,第6章将詳細讨論類(類是引用類型)。現在隻需知道引用類型的變量包含的隻是對實際資料所在位置的一個引用,而不是直接包含實際資料。将變量設為null,會顯式設定引用,使其不指向任何位置(空)。事實上,甚至可以檢查引用是否為空。代碼清單2.19示範了如何将null賦給string變量。
将null賦給引用類型的變量和根本不指派是不一樣的概念。換言之,指派了null的變量已設定,而未指派的變量未設定。使用未指派的變量會造成編譯時錯誤。
将null值賦給string變量和為變量指派""也是不一樣的概念。null意味着變量無任何值,而""意味着變量有一個稱為“空白字元串”的值。這種區分相當有用。例如,程式設計邏輯可将為null的homePhoneNumber解釋成“家庭電話未知”,将為""的homePhoneNumber解釋成“無家庭電話”。
2.3.2 void
有時C#文法要求指定資料類型但不傳遞任何資料。例如,假定方法無傳回值,C#就允許在資料類型的位置放一個void關鍵字。HelloWorld程式的Main方法聲明就是一個例子。在傳回類型的位置使用void意味着方法不傳回任何資料,同時告訴編譯器不要指望會有一個值。void本質上不是資料類型,它隻是指出沒有資料類型這一事實。
2.4 資料類型轉換
考慮到各種CLI實作預定義了大量類型,加上代碼也能定義無限數量的類型,是以類型之間的互相轉換至關重要。會造成轉換的最常見操作就是轉型或強制類型轉換(casting)。
考慮将long值轉換成int的情形。long類型能容納的最大值是9 223 372 036 854 775 808,int則是2 147 483 647。是以轉換時可能丢失資料—long值可能大于int能容納的最大值。有可能造成資料丢失或引發異常(因為轉換失敗)的任何轉換都需要執行顯式轉型。相反,不會丢失資料,而且不會引發異常(無論操作數的類型是什麼)的任何轉換都可以進行隐式轉型。
2.4.1 顯式轉型
C#允許用轉型操作符執行轉型。通過在圓括号中指定希望變量轉換成的類型,表明你已确認在發生顯式轉型時可能丢失精度和資料,或者可能造成異常。代碼清單2.20将一個long轉換成int,而且顯式告訴系統嘗試這個操作。
程式員使用轉型操作符告訴編譯器:“相信我,我知道自己正在幹什麼。我知道值能适應目标類型。”隻有程式員像這樣做出明确選擇,編譯器才允許轉換。但這也可能隻是程式員“一廂情願”。執行顯式轉換時,如資料未能成功轉換,“運作時”還是會引發異常。是以,要由程式員負責確定資料成功轉換,或提供錯誤處理代碼來處理轉換不成功的情況。
轉型操作符不是萬能藥,它不能将一種類型任意轉換為其他類型。編譯器仍會檢查轉型操作的有效性。例如,long不能轉換成bool。因為沒有定義這種轉換,是以編譯器不允許。
2.4.2 隐式轉型
有些情況下,比如從int類型轉換成long類型時,不會發生精度的丢失,而且值不會發生根本性的改變,是以代碼隻需指定指派操作符,轉換将隐式地發生。換言之,編譯器判斷這樣的轉換能正常完成。代碼清單2.24直接使用指派操作符實作從int到long的轉換。
如果願意,在允許隐式轉型的時候也可強制添加轉型操作符,如代碼清單2.25所示。
2.4.3 不使用轉型操作符的類型轉換
由于未定義從字元串到數值類型的轉換,是以需要使用像Parse()這樣的方法。每個數值資料類型都包含一個Parse()方法,允許将字元串轉換成對應的數值類型。如代碼清單2.26所示。
還可利用特殊類型System.Convert将一種類型轉換成另一種。如代碼清單2.27所示。
但System.Convert隻支援少量類型,且不可擴充,允許從bool、char、sbyte、short、int、long、ushort、uint、ulong、float、double、decimal、DateTime和string轉換到這些類型中的任何一種。
此外,所有類型都支援ToString()方法,可用它提供類型的字元串表示。代碼清單2.28示範了如何使用該方法,輸出2.17展示了結果。
輸出2.17
大多數類型的ToString()方法隻是傳回資料類型的名稱,而不是資料的字元串表示。隻有在類型顯式實作了ToString()的前提下才會傳回字元串表示。最後要注意,完全可以編寫自定義的轉換方法,“運作時”的許多類都存在這樣的方法。
進階主題:TryParse()
從C# 2.0(.NET 2.0)起,所有基元數值類型都包含靜态TryParse()方法。該方法與Parse()非常相似,隻是轉換失敗不是引發異常,而是傳回false,如代碼清單2.29所示。
輸出2.18展示了結果。
輸出2.18
上述代碼從輸入字元串解析到的值通過out參數(本例是number)傳回。
注意從C# 7.0起不用先聲明隻準備作為out參數使用的變量。代碼清單2.30展示了修改後的代碼。
注意先寫out再寫資料類型。這樣定義的number變量隻有if語句内部的作用域,在外部不可用。
Parse()和TryParse()的關鍵差別在于,如轉換失敗,TryParse()不會引發異常。string到數值類型的轉換是否成功,往往要取決于輸入文本的使用者。使用者完全可能輸入無法成功解析的資料。使用TryParse()而不是Parse(),就可以避免在這種情況下引發異常(由于預見到使用者會輸入無效資料,是以要想辦法避免引發異常)。
2.5 小結
即使是有經驗的程式員,也要注意C#引入的幾個新程式設計構造。例如,本章探讨了用于精确金融計算的decimal類型。此外,本章還提到布爾類型bool不會隐式轉換成整數,防止在條件表達式中誤用指派操作符。C#其他與衆不同的地方還包括:允許用@定義逐字字元串,強迫字元串忽略轉義字元;字元串插值,可在字元串中嵌入表達式;C#的string資料類型不可變。
下一章繼續讨論資料類型。要讨論值類型和引用類型,還要讨論如何将資料元素組合成元組和數組。