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


導航

第二章 Core C#

2.1 C#基礎

通過上一章你了解了C#都能做啥,接下來我們需要了解怎么使用C#。在接下的各個小節里將向你介紹C#編程的一些基礎知識,引導你開始使用C#。在本章的結尾你應該會對C#有個簡單的認識,在后續的章節我們會持續介紹繼承和其他一些面向對象的特性。

2.1.1 Hello,World!

第一章的時候我們演示了如何通過.NET Core CLI工具創建一個HelloWorld應用程序。現在讓我們將注意力集中到C#源代碼上。首先,我必須先對C#的基本語法說一個說明。在C#中,與其他C風格語言相同的是,代碼語句是以分號;結尾的,而且可以不使用連接符書寫多行的代碼語句。代碼可以被一對大小括號{}包起來形成一段代碼塊。單行注釋是以//開頭的,而多行注釋則以/*開始,以*/結束。以上的這些內容,C#和C++還有Java是完全一樣的,卻與Visual Basic截然不同。分號和大小括號在VB里的定義與C#完全不同。如果你曾經是一個VB程序員,使用C#的時候要注意為每行代碼的末尾加上分號;。忘記寫分號是C風格語言的新手最容易犯的錯誤了。另外需要注意的是C#里是大小寫區分的,這意味着myVar和MyVar是兩個完全不同的變量。

代碼頭的數行是對命名空間的聲明,namespace關鍵字定義了你的class在程序集里將會如何組織和聯動。namespace后緊跟的一對大括號里的所有代碼將被編譯器認為是屬於同一個命名空間下的。using聲明則告訴編譯器,當代碼中出現當前namespace中不存在的類時,應該去哪些命名空間中查找。這個方式與Java的import語句,C++的using namespace語句效果是一樣的。請看一下HelloWroldApp/Program.cs中的源代碼:

using System;
namespace Wrox.HelloWorldApp
{
    // ...
	Console.WriteLine("Hello World!");
}

在Program.cs文件頭聲明了using System;,這是因為在接下來的代碼里你將使用Console類,這個類是由命名空間System下提供的,全稱是System.Consoleusing System;聲明語句使得你可以在代碼里直接使用Console.WriteLine而不需要寫成System.Console.WriteLine,假如你需要在代碼里多次使用Console,提前聲明會讓你的代碼變得更加簡潔易讀。

使用using static聲明你甚至可以直接使用相應類里的靜態成員,通過using static System.Console聲明,你可以直接調用Console的WriteLine方法,而不需要再寫上Console,就如下所示:

using static System.Console;
// ...
WriteLine("Hello World!");

作為最基礎的類庫,System命名空間幾乎是.NET里引用次數最多的。你用C#編寫的所有代碼幾乎都依賴於.NET基礎類。在本例中,你用的是System命名空間下的Console類,將文本內容輸出到控制台窗口中。C#沒有任何內置的關鍵字定義輸入和輸出,它完全依賴於.NET類庫。

在源代碼里,我們聲明了一個叫Program的類。因為它被包含在命名空間Wrox.HelloWorldApp中,因此這個類有效的名稱應該是Wrox.HelloWorldApp.Program:

namespace Wrox.HelloWorldApp
{
	class Program
	{
        //...
    }
}

所有的C#代碼都必須包含在某個類中。一個類的聲明由class關鍵字和隨后的類名組成,然后是一對大括號,所有跟這個類有關聯的代碼需要寫在這對大括號內部。

Program類里包含了一個叫Main的方法。每個C#可執行程序(如控制台程序,Windows程序,Windows服務或者Web應用程序)都需要一個入口點——Main方法,注意開頭的M是大寫的。

static void Main()
{
    //...
}

當程序運行的時候,會首先調用這個方法。這個方法的返回類型只能是空(void)或者整型(int)。

[modifiers] return_type MethodName([parameters])
{
	// 方法體,這里是偽代碼
}

第一個方括號代表可選的關鍵字,Modifiers用來指定你定義的Method的某些特性,比如這個方法將會從哪里調用。在我們的示例代碼里,Main方法並沒有加上public訪問修飾符。你可以給它加上public修飾符假如你需要對Main方法進行單元測試的話。運行時不需要關注Main方法的可訪問性就可以直接調用這個方法。static修飾符修飾的方法告訴運行時,在調用此方法時,不需要創建類的實例。示例中的返回值被設置為void,並且parameters是空的,也就是說方法不帶有任何參數。

最后,讓我們看看實際的執行代碼:

Console.WriteLine("Hello World!");

在這個例子中,你簡單的從System.Console類中調用了它的WriteLine方法,將一行文本輸出到控制台窗口中。WriteLine是一個static方法,所以你不必要實例化一個Console類的實例就可以直接調用它。

現在你已經初步體會過C#的語法了,可以給你演示更多的細節了。因為在實際應用過程中,你不可能一個變量都用不到的,因此我們接下來講解C#的變量。

2.2 變量

在C#里你將使用以下的語法定義變量:

datatype identifier

舉個例子:int i;,這個語句定義了一個叫i的int整型變量。在你給這個變量賦值之前,編譯器不允許你直接在表達式里使用這個變量。聲明變量后,你可以使用=給這個變量賦值,如下所示:

i = 10;

你也可以在定義這個變量的時候同時初始化它的值:

int i = 10;

如果你想一次性定義多個同類型的變量值並寫在一行里的話,可以像下面這樣寫:

int x = 10,y = 20;//x和y都是int類型

如果你想定義不同類型的變量,你需要使用多個獨立的定義語句,你不能在一個語句里同時定義不同類型的變量:

int x=10; bool y=true; //正確的,一個分號代表一個語句的結束
int x=10, bool y=true; //錯誤的,會提示bool前面應該使用;號

注意上面示例中的//,后續的文本屬於注釋內容。//字符告訴編譯器,從這個字符往后的字符直接忽略,直到遇到換行符。注釋主要是方便研發人員查看的,並不屬於程序的一部分,所以不需要編譯。

2.2.1 變量初始化

所有變量必須先初始化證明了C#是一門強調安全的語言。簡單來講,C#編譯器要求所有變量在進行任何操作前都先賦初值。大部分的現代編譯器將操作沒有賦值的變量僅僅標記為warning,但是嚴謹的C#編譯器將這個行為認為是error。

C#有兩個方法負責確認變量在使用前是否正確初始化:

  • 變量通常是class或者struct里的字段,如果沒有顯式初始化,當創建變量的時候會嘗試給它們賦初值0(class和struct的初值我們延后討論)。
  • 方法里的局部變量在任何語句使用它們之前必須顯式初始化,這種情況下,變量不需要在聲明的時候直接賦初值,但是編譯器會檢查方法里的所有可能賦值的地方,假如在初始化前就對變量進行其他操作,編譯器會直接標記一個錯誤。

舉個例子,以下的代碼是不行的:

static int Main()
{
	int d;
	Console.WriteLine(d); // 編譯時會提示使用了未賦值的變量d
	return 0;
}

注意上述代碼演示了一個帶有int返回值的Main方法。考慮以下語句:

Something objSomething;

在C#里,這行代碼創建了一個Something類的實例引用,但這個引用還沒有指向任何實際的對象,任何嘗試調用objSomething方法或者屬性的操作都會報錯。

對象初始化你需要用到new關鍵字,你可以像上面一樣創建一個引用,然后將這個引用指向托管堆上用new關鍵字創建的一個實例對象:

objSomething = new Something();

2.2.2 類型診斷

診斷類型是通過使用var關鍵字,代替實際的數據類型。編譯器會檢查該變量在何時初始化,並且根據初始值的類型,決定該變量的實際數據類型是啥。舉個例子:

var someNumber = 0;
//實際上就是
int someNumber = 0;

一開始我以為這個關鍵字和Javascript一樣,定義的是一個可變類型,於是我這么寫:

var i = 10;
i = "test"; //錯誤:無法將類型string隱式的轉換成int

關於var的使用,有這么幾條規則:

  • 變量必須初始化,否則編譯器無法知道變量究竟對應什么類型。
  • 變量不能初始化為null。
  • 變量初始化必須是表達式,常量也算表達式。
  • 你不能將實例對象直接賦值給var變量,你需要顯式創建一個新的實例。
//失敗示例1
var i;
object o = new object();
i = o;
//失敗示例2
var j;
j = new object();
//成功示例
var k = new object();

第三章我們會更進一步的解釋這種匿名類型的使用。

2.2.3 變量的作用域

所謂變量作用域就是代碼中的變量在哪些代碼里有效能用。簡單來講有以下幾條規則:

  • 字段field(也可以認為是成員變量)在一個類中的作用域就跟在這個類中定義的任何內部(local)變量一樣。
  • 內部變量的作用域為用一對大括號包起來的代碼段或者方法內。
  • 定義在for,while或者類似的語句中的內部變量的作用域為循環體。

內部變量的作用域沖突

在一個大型程序中,在不同部分的代碼中使用相同的變量名也不罕見。這種做法是允許的,只要你能明確同一個變量名的作用域,不要引起沖突和歧義即可。然而請謹記,在相同的作用域內,同名變量不能聲明兩次,下面這樣子明顯就是錯的:

int x = 20;
int x = 30;

考慮以下的示例代碼:

using System;
namespace VariableScopeSample
{
	class Program
	{
		static int Main()
		{
			for (int i = 0; i < 10; i++)
			{
				Console.WriteLine(i);
			}
		
			for (int i = 9; i >= 0; i –)
			{
				Console.WriteLine(i);
			}
		
            return 0;
		}
	}
}

這段代碼先是在控制台上輸出0-9,然后輸出9-0,使用了兩個for循環。需要注意的是我們在同一個方法中聲明了兩次i,之所以能這么做是因為i是定義在兩個分開的循環體內的,對每個for循環來說,i都是它的內部變量。

再來看看以下這個例子:

static int Main()
{
	int j = 20;
	for (int i = 0; i < 10; i++)
	{
		int j = 30; // 無法在此范圍中聲明名為"j"的局部變量或參數 
		Console.WriteLine(j + i);
	}
	return 0;
}

這個錯誤提示是因為在for循環外,我們已經聲明了變量j,它的作用域為整個Main方法的內部代碼,就相當於在for循環內部已經存在一個變量j了,所以編譯器不允許在for循環內重復聲明同一個變量j。

內部變量和字段的作用域沖突

在特定環境下,你可以區分兩個同名標識符(雖然不一定完全相同)以及相同的作用域,而且在這種情況下編譯器允許你聲明第二個變量。這是因為C#對於變量有一個稱之為Type Level的基礎划分。變量可以被聲明為類的內部字段(fields),屬於class-level,或者聲明為方法的內部變量(local variables)。考慮以下代碼:

using System;
namespace Wrox
{
	class Program
	{
		static int j = 20;
		static void Main()
		{
			int j = 30;
			Console.WriteLine(j);
			return;
		}
	}
}

這段代碼可以被正常編譯,雖然你也聲明了兩個同名變量j而且他們的作用域在Main方法內有所交叉,編譯器是這么做的判斷,定義在Program的內部字段j屬於class-level,而Main方法內定義的屬於內部變量(原文扯了一大堆兩者生命周期不同之類的,我覺得關系不太大,最重要的還是因為字段屬於class-level,比內部變量高級,嗯,官大一級那個啥,所以我們不是同一級的存在,可以並存。而先前的例子,兩個j都是內部變量,平級的,對不起,只能活一個)。在這種情況下,運行Main方法的時候,Main方法內部的變量j會隱藏外部的字段j,所以當你運行這段代碼的時候,你會看到控制台輸出的是30。

那假如你想引用外部的字段j怎么辦?你可以使用object.fieldname。如上面的實例你可以這樣引用:

Console.WriteLine(j);//30
Console.WriteLine(Program.j);//20

因為上面的Main是靜態方法,所以我們聲明的字段是用static修飾符修飾的靜態字段,可以通過類名.字段直接引用。如果想在普通的class實例中引用被隱藏的字段的話,你可以使用this關鍵字。

2.2.4 常量

就跟名字暗示的一樣,常量就是生命周期里一直保持一個值的變量。當某個變量需要被定義和初始化為常量時,在變量前面加上const關鍵字修飾即可:

const int a = 100; //值不能修改

常量有以下的一些特性:

  • 常量聲明的時候必須初始化,並且一旦它初始化了,值就不允許再次改變。
  • 常量必須是在編譯時能明確計算出結果的,例如你不能初始化常量為某個變量名。如果你有需要這么做的話,請使用read-only字段。
  • 常量往往是隱式static類型。然而你並不需要(事實上也不允許)在聲明常量的時候加上static關鍵字修飾。

使用常量至少有三個好處:

  • 常量可以使你的程序代碼更容易閱讀,因為你把固定的數字和字符串用有意義的變量名稱代替了,名字比數值更容易讓人理解。
  • 常量使你的程序代碼更容易修改。舉個例子,假如你在代碼里定義了一個SalesTax常量,最初的時候它是6%。然后你引用它寫了許多代碼。某天稅率突然改變了,你只需要修改一處常量的值即可,而不需要修改大量的代碼。
  • 常量能夠幫你避免一些錯誤。假如你在程序的某個地方想聲明一個同名變量,而這個變量已經被聲明過了,編譯器就會直接給你提示一個錯誤。

2.3 預定義數據類型

現在你知道如何定義變量和常量了,讓我們更深入的了解一下C#里面有哪些數據類型。就像你看到的,C#比其他語言在類型和定義上都要嚴格的多。

2.3.1 值類型和引用類型

在研究C#究竟有哪些數據類型之前,我們非常有必要了解C#將數據類型分為兩大類:值類型和引用類型。

接下來的小節會主要介紹值類型和引用類型的語法區別。而從概念上來講,兩者的區別在於值類型是直接存儲的變量值,存儲在棧(stack)上;引用類型的存儲的是一個引用地址,它的實際值存在托管堆(managed heap)上。你必須能清楚的分辨一個類型究竟是值類型還是引用類型,因為兩者的有效性(effect)和資源分配(assignment)完全不一樣。舉個例子,int是一個值類型,以下語句會在內存棧空間里划出兩塊空間,存儲的都是值20:

int i = 20;
int j = i;

我們再看看下面這個例子,在代碼里,假設你定義了一個類,名字叫Vector。Vector是一個引用類型,並且它擁有一個int型的成員變量叫Value:

Vector x, y;
x = new Vector();
x.Value = 30;
y = x;
Console.WriteLine(y.Value);//30
y.Value = 50;
Console.WriteLine(x.Value);//50

理解這段代碼為什么會得出這樣的結果的關鍵點在於,至始至終,只存在一個Vector實例,x和y是指向同一實例的兩個地址引用(point to the same memory location)。因為x和y都是引用類型的變量,聲明引用變量只會簡單的返回一個地址引用——它不會按照聲明的class類型創建一個包含數據的實例對象。只有使用new關鍵字,一個對象實例(object instance)才會被創建。因為x和y指向的是同一個實例,因此對x和y成員的修改事實上都會互相影響。所以上面例子的先輸出30然后輸出50。

如果一個變量是引用類型,你可以通過給它賦值為null來說明它沒有指向任何實例對象:

y = null;

如果一個引用類型的變量被設置成null了,那么它就不能調用任何非靜態的成員方法或者字段,否則運行時會提示一個System.NullReferenceException。

在C#里,基礎類型(如bool和long)都是值類型。這意味着如果你聲明了一個bool類型的變量,並且將它賦值給另外一個bool類型的變量,你將會在內存里分配兩個bool值的空間。然后,當你修改第一個bool的值的時候,第二個bool變量的值並不會改變。值類型是值的拷貝。

與之相對應的是,在C#中大部分的復合數據類型,也包括你自己定義的class,都是引用類型。他們的實例存儲在托管堆上,可以被各種方法調用,也可以通過別名引用。CLR實現了一套精准的算法來追蹤引用類型中哪些變量仍然在用以及哪些變量已經不再被引用。CLR會周期性地銷毀這些不再引用的實例,回收它們占用的系統內存。這套機制稱之為垃圾回收(GC,Garbage Collector)。

C#里將基礎類型(如int和bool)當做值類型,將包含有很多字段屬性方法的大類型(通常就是class)當做引用類型處理,這種設計擁有最好的性能(high performance is best served)。如果你想自定義一個值類型的數據類型,你可以使用struct關鍵字定義一個結構體。

注意:基礎數據類型的設計(layout of primitive data types)通常與機器設計(native layout)一致。這樣使得可以在托管代碼和機器代碼之間共享內存。

2.3.2 .NET 類型

C#關鍵字定義的數據類型,如int,short和sring等等,實際上映射的是各種.NET數據類型。舉個例子,當你在C#里聲明了一個int類型,你實際上創建的是一個.NET struct實例:System.Int32。可能這聽起來無關緊要,事實上它意義重大。這意味着你可以將基礎數據類型,在語法上當做class類型一樣,支持各種方法調用。舉個例子,為了將int類型的變量i轉換成一個字符串類型string,你可以像下面這么寫:

string s = i.ToString();

需要強調的是,在這個便利的語法背后,int數據類型確實是存儲成基礎數據類型的。你這么寫不會有任何的性能損失,因為實際上存儲的類型是C#的結構體struct:System.Int32,與它相關的ToString方法早就加載到內存中了。

接下來的章節會帶你回顧C#內置的數據類型。下面列舉的各種基礎數據類型,每個都有自己對應的.NET類型。C#擁有15個預定義的數據類型,包括13個值類型與2個引用類型(string和object)。

2.3.3 預定義的值類型

內置的.NET值類型代表各種基礎數據類型,比如整數、浮點數、字符和布爾值。

整型

C#支持8個預定義的整型類型,如下表所示:

關鍵字 .NET struct 描述
sbyte System.SByte 8位有符號整數
short System.Int16 16位有符號整數
int System.Int32 32位有符號整數
long System.Int64 64位有符號整數
byte System.Byte 8位無符號整數
ushort System.Int16 16位無符號整數
uint System.Int32 32位無符號整數
ulong System.Int64 64位無符號整數

有些C#類型和C++或者Java的類型有相同的名稱,但是它們的定義卻不一樣。舉個例子,在C#里面,int類型總是32位帶符號數。在C++里int雖然也是帶符號數,但它代表多少位bits取決於它運行的平台(32位系統它是32位,64位系統則是64位)。在C#里,所有的數據類型都被定義為平台無關的類型,以便C#和.NET程序可以后續運行在其他平台上。

byte類型是一個標准的8-bit類型,取值范圍為0-255。值得注意的是,作為一門強調類型安全的語言,C#里,byte類型和char類型是完全不同的兩種類型,這兩種數據類型要進行運算的話,需要顯式地進行轉換。另外,與其他語言不同的是,C#里的byte默認是無符號數。帶符號的8位整數在C#里被定義為sbyte。

在.NET中,short類型不再是那么短了(有些語言short是8位),它足足有16位長。int類型則代表了32位長度的整數,long類型則提供64位長的整數。所有的整型變量都可以由10進制數,16進制數或者二進制進行初始化。二進制數需要以0b開頭,而16進制數則要求以0x開頭,如:

long x = 0x12ab;

假如判斷一個整數究竟屬於int,uint還是long類型存在歧義,C#則默認這個整數是int類型。為了指定一個整數值究竟是哪種特定類型,你可以給它們添加相應的后綴,如下所示:

uint ui = 1234U;
long l = 1234L;
ulong ul = 1234UL;

當然你也可以用小寫的u和l,只是小寫的l容易和數字1混淆。

數字分隔符

C# 7.0提供的新特性。這些分隔符只是為了增加數字的可讀性而已,並未添加任何其他的功能。舉個例子,你可以在數字中添加一個下划線,如下面所示:

long l1 = 0x1234_5678_90ab_cedf;

編譯器會自動忽略這個分隔符,其實上面這個值就等效於:

long l2 = 0x1234567890abcdef;
Console.WriteLine((l1 == l2).ToString());//true

因為這個特性,你可以在數字的任意位置加分隔符號,以增強數據可讀性,但注意請不要任性地在一些毫無意義的地方添加:

long l3 = 0x12345_67890_abc_ed_f;

這個特性在很多地方都有用,譬如使用16進制或者8進制的時候,又或者使用一些協議中的數據位的時候。

注意:C# 7.0特性剛推出的時候,不允許在數字第一位之前和數字最后一位之后添加分隔符。在C# 7.2的時候允許在數字第一位之前加分隔符。

在二進制數中使用

除了數字分隔符的特性,C# 7.0還讓你更容易地進行二進制賦值。如果你在一個數字前面敲了0b,接下來就只允許使用0和1,只有二進制數值允許進行賦值。讓我們來試試輸入了非0和1的值會怎么樣:

uint binary1 = 0b123456789;
//只有assignment、increment、decrement和new對象表達式可以作為語句。
//應輸入;

為什么編輯器會這么提示呢?這是因為C# 7.0之后,0b開頭的數字后面,非0和1的數字直接被認為不屬於有效數字,上面其實就相當於我們在編輯器里敲了:

uint binary1 = 0b1
23456789;  

那么編輯器的提示就好理解了,第一行應該輸入;結束,而第二行不是有效的語句,所以才有了前面注釋里的那兩行提示。

讓我們再考慮以下的二進制初始化:

uint binary1 = 0b1111_1110_1101_1100_1011_1010_1001_1000;

上述代碼片段定義了一個32位的無符號整型數。數字分隔符可以幫你更輕松地看懂這串二進制數的值。這串二進制數每4位被分在一起並由下划線分隔開。當然你也可以像下面這么寫,但明顯上面的更直觀:

uint hex1 = 0xfedcba98;

通常我們在使用八進制數的時候會按每3位進行分隔,如:

uint binary2 = 0b111_110_101_100_011_010_001_000;

接下來的例子演示某種協議中的二進制位寫法,該協議按照具體用途分為最右兩位,接下來6位為一小節,再接下來兩個4位的16位二進制數,那么我們可以這么寫:

ushort binary3 = 0b1111_0000_101010_11;

注意:

  • 使用的時候要使用精確的整型類型,例如16位的二進制用ushort,32位的用uint,64位的用ulong。
  • 二進制字面量從C# 7.0開始。

浮點數

C#不僅提供了盡可能詳細的整型數據類型,同樣也提供了小數類型。

類型 .NET struct 描述 小數位
float System.Single 32位,單精度浮點數 7
double System.Double 64位,雙精度浮點數 15/16

float主要用於值不太大,也不要求太高精度(7位)的浮點數。而double則比float大多了,並且提供了2倍的精度(15位)。

假如你在程序中直接使用一個非整型的數值(比如12.3),編譯器會默認你想使用一個double類型的浮點數。如果你想指定該值只是一個float類型的浮點數,你需要在數字后面加上一個字符F,如下所示:

float f = 12.3F;

Decimal類型

decimal類型代表了更高精度的浮點數,如下表所示:

類型 .NET struct 描述 小數位
decimal System.Decimal 128位,高精度浮點數 28

在C#和.NET中decimal最常用的就是進行金融計算,你可以使用高達28位的小數位的高精度浮點數。換句話說,你可以追蹤更高精度的美元數值或者大宗交易里的分數部分。但你要記住的是,decimal在底層並不是作為基礎數據實現的,因此使用decimal計算會對你程序的性能有所影響。

要指定你的數據是一個decimal類型而不是默認的其他數值類型,你需要在你的數值后面加上一個字符M(或者m):

decimal d = 12.30M;

布爾值

C#里bool類型,對應的.NET類型為struct System.Boolean,僅包含兩個值true和false。

你不能將一個整型數值隱式的轉換為一個bool值。如果你使用了一個bool變量或者返回bool值的方法,你只能使用true或者false。如果你試圖用0代表false或者用非0的數值代表true都編譯器都會給你提示一個error。

char類型

為了表示單個字符,C#提供了char類型。它對應的.NET類型是struct System.Char。char的字面量賦值通常由單引號括起來,例如:

char c = 'A';

假如你嘗試將一個包裹在雙引號里的字符賦值給char類型,編譯器會認為那是一個字符串,然后會提示你類型轉換錯誤。如:

char c = "A"; //無法將類型string隱式地轉換成char

因為代表的是單獨一個字符,你也可以用4位的Unicode來表示它如,'\u0041',或者用一個整型數值的強制轉換,如 (char)65,也可以用16進制值來表示,如'\x0041'。你可以使用轉義字符,如\'

2.3.4 預定義的引用類型

C#支持兩種預定義的引用類型,object和string,如下表所示:

類型 .NET class 描述
object System.Object 根類型,所有其他的類型,包括值類型,都繼承自object。
string System.String Unicode編碼的字符串。

object類型

許多的程序設計語言和類體系都提供了一個基類(root type),其它的類型都派生於這個基類。C#和.NET也不例外。在C#里,object類型是最頂級的類(ultimate parent type),所有系統的類和用戶自定義的類型都派生自它。這意味着object有兩個用途:

  • 你可以使用object引用來表示任何子類型。舉個例子,在第六章,"運算符和轉換"中,你可以了解到,如何使用object類型對一個值類型進行裝箱(box a value object),把它從棧移動到托管堆上。object引用在反射(reflection)中也很有用,當代碼需要用到一些不確定具體類型的類時。
  • object類型實現了不少基礎通用的方法,包括Equals,GetHashCode,GetType和ToString。用戶自定義的類在某些時候可能需要實現自定義的基礎方法,你可以使用一項面向對象的技術,叫作重寫(override),第四章將詳細介紹這部分內容。舉個例子,有時候你需要重寫ToString方法,來精確地(intelligently)提供你自定義類的具體含義(string representation)。如果你沒有提供你自己的實現,編譯器會自動調用object類型的ToString方法,輸出的內容可能對於自定義類的上下文來說不正確也沒有任何意義。

你將會在后續的章節接觸到更多關於object類型的細節。

string類型

C#能識別string關鍵字,在底層,它被轉義成.NET類System.String。通過它,字符串的連接操作或者拷貝只是小菜一碟:

string str1 = "Hello ";
string str2 = "World";
string str3 = str1 + str2;

除了這種賦值方式以外,string是一個引用類型。事實上,一個string類型的實例被創建在托管堆上,而不是棧。當你將一個字符串類型的變量賦值給另外一個字符串變量,按照引用類型的定義來說的話,你將得到的是兩個指向同一字符串的變量引用。然而,string類型和常見的引用類型的表現不同。舉個例子,string串的文本內容是保持不變的。對字符串進行的任何操作,都會創建一個新的string對象實例,原始的string內容並不會發生任何改變。考慮以下的代碼:

using System;
class Program
{
	static void Main()
	{
		string s1 = "a string";
		string s2 = s1;
		Console.WriteLine("s1 is " + s1); //a string
		Console.WriteLine("s2 is " + s2); //a string
		
        s1 = "another string";
		Console.WriteLine("s1 is now " + s1);//another string
		Console.WriteLine("s2 is now " + s2);//a string
	}
}

修改s1的值並不會影響到s2,你會想,不對啊,string不是引用類型嘛!為什么s2的值沒有變化呢?我們來詳細描述一下每一句賦值語句都做了啥:

  • 第一句,在托管堆上創建了一個字符串"a string",在棧上創建了一個引用s1,將s1指向堆上的"a string";
  • 第二句,在棧上創建一個引用s2,指向s1引用對應的堆,因此在打印s2的時候,輸出的也是"a string",注意此時s1和s2指向的是同一塊內存空間。
  • 關鍵在於第三句,將"another string"賦值給s1的時候,並非在堆上直接修改"a string"的內存,而是在堆上重新創建了一個"another string"的string對象實例,並且把s1指向新的字符串。
  • 因為s2指向的還是舊的內存地址,內容仍然是"a string"沒有發生更改。

這涉及到String類對運算符"="的重載(overload),第六章我們將詳細介紹這部分。總的來說,string類被重新設計實現了,因此你可以簡單直觀地將它當成一個值類型來使用(事實上它是引用類型)。

String的字面量被包含在兩個雙引號""中。如果你嘗試用單引號賦值,編譯器會報錯,如下所示:

string s = 'test'; //字符文本中的字符太多
string s = 's'; //無法將char類型隱式轉換成string類型

String一樣可以使用Unicode和十六進制作為其中的某個字符組成。因為這種轉義序列是以反斜杠\開頭的,所以你在string中不能直接使用這個字符。不過,你可以用兩個反斜杠\\代替,它代表的是一個字符\,如:

string filepath = "C:\\ProCSharp\\First.cs";
Console.WriteLine(filepath.Length.ToString()); //21
string s = "\\";
Console.WriteLine(s.Length.ToString()); //1

注意:\和C盤分區操作文件目錄僅適用於Windows操作系統。在C#代碼里,可以使用斜杠/來統一分隔文件目錄。第22章"文件和流"會介紹更多詳細信息。

就算你自信你可以在編寫文件和目錄路徑的時候,每次都記得不同的系統怎么寫,但要記住當前是\\還是/都是一件非常煩人的事情。幸運的是,C#提供了一個更方便的方式。你可以在字符串前面敲一個@字符,這樣,字符串里的所有字符都會當成原始值(face value)進行處理,\不會進行任何轉義:

string filepath = @"C:\ProCSharp\First.cs";

這個特性甚至允許你在字符串里使用回車換行而不用手動敲入\r\n之類的轉義字符:

string jabberwocky = @"'Twas brillig and the slithy toves
Did gyre and gimble in the wabe."; //直接就輸出帶回車換行的兩行字符串

C#還定義了用$前綴表示的一種轉義格式。你可以將早先的代碼片段修改成使用這種字符串轉義格式。用$前綴標識的允許你在字符串里用{}包裹的形式書寫變量或者表達式,實際輸出的是變量或者表達式的值,如下所示:

public static void Main()
{
	string s1 = "a string";
	string s2 = s1;
	Console.WriteLine($"s1 is {s1}");
	Console.WriteLine($"s2 is {s2}");
	s1 = "another string";
	Console.WriteLine($"s1 is now {s1}");
	Console.WriteLine($"s2 is now {s2}");
    string s3 = s1 + ' ' + s2;
    Console.WriteLine($"s3 is now {s3}");
	Console.WriteLine($"s1 concat s2 is now {s1 + ' ' + s2}");
}

更多的關於string類型的轉義操作將在第9章進行介紹。

2.4 程序流控制

本小節主要介紹語言的基本組成部分:控制流語句,它可以使你的程序代碼按照你的需要而不是逐行全部執行。

2.4.1 條件語句

條件語句使得你可以將你的代碼根據真實的情況分成不同的執行部分。C#擁有兩個條件語句的構造器:if語句可以用來判斷指定條件是否滿足,而switch語句則比較表達式產生可能產生的多個結果值。

if語句

C#繼承了C和C++的if.else條件選擇語句。相信有過一些編程經驗的讀者一定很熟悉這個結構:

if (condition){
    statement(s)
}
else{
    statement(s)
}

如果每個條件下需要執行多個語句,這些語句需要用大括號{}包裹起來(這個要求和C#的其他語句構造體一樣,如for循環和while循環):

bool isZero;
if (i == 0)
{
	isZero = true;
	Console.WriteLine("i is Zero");
} 
else
{
	isZero = false;
	Console.WriteLine("i is Non-zero");
}

根據需要,你可以只寫一個if語句而不帶有任何else,你也可以使用else if當你需要處理更復雜的條件判斷時:

using System;

namespace Wrox
{
    internal class Program
    {
        private static void Main()
        {
            Console.WriteLine("Type in a string");
            string input;
            input = Console.ReadLine();
            if (input == "")
            {
                Console.WriteLine("You typed in an empty string.");
            }
            else if (input.Length < 5)
            {
                Console.WriteLine("The string had less than 5 characters.");
            }
            else if (input.Length < 10)
            {
                Console.WriteLine(
                "The string had at least 5 but less than 10 Characters.");
            }
            Console.WriteLine("The string was " + input);
        }
    }
}

if語句后面的else if數量沒有任何限制。上面的例子定義了一個input變量,用來接收控制台輸入的字符,並且通過input.Length來判斷輸入了多個字符,輸出相應的提示信息。這個例子向你演示了在C#里操作字符串有多么的方便。

(原文中有這么一段)另外需要注意的一點是,關於if語句,當判斷條件只有一個表達式的時候,你可以省略()括號。然而為了代碼一致性,大部分的程序員都不會在使用if語句的時候省略括號:

if (i == 0)
	Console.WriteLine("i is Zero"); // 只有i等於0的時候會輸出
if i == 0
	Console.WriteLine("i can be anything"); // 不管i是什么值都會輸出

將書里提供的例子敲到VS2019里,會提示"語法錯誤:需要輸入(",可能在某些地方修改配置能使編譯通過並執行,但這種特性容易引起更多的錯誤,不建議使用。

另外就是再次強調一下,作為if的判斷條件,只能是true或者false或者返回true/false的表達式,C#不允許使用整型代替。

switch語句

switch / case 語句提供了一種更方便的多分支條件選擇。它的格式是一個switch語句附帶一系列的case語句。當switch語句的表達式算出確定的值的時候,代碼會迅速轉到相應的case語句進行執行。下面是一個例子,你不需要為每個case語句所屬的代碼段都用{}包裹,只需要在結尾寫上break語句即可。你還可以在switch中添加default語句,當任何case都不滿足表達式條件時,就會進入default里執行。下面例子演示了如何判斷一個變量integerA的值:

switch (integerA)
{
	case 1:
		Console.WriteLine("integerA = 1");
		break;
	case 2:
		Console.WriteLine("integerA = 2");
		break;
	case 3:
		Console.WriteLine("integerA = 3");
        Console.WriteLine("integerA is not 1 or 2.");
		break;
	default:
		Console.WriteLine("integerA is not 1, 2, or 3");
		break;
}

注意case之后的值必須是常量,不能是表達式,也不能是任何變量。

雖然switch.case語句和C還有C++很像,但是C#的switch.case比他們要稍微安全一些。具體來說就是,C#禁止從一個case語句跳轉到另外一個case語句。這意味着除非你主動使用goto語句從一個case才能跳到另外一個case。編譯器會檢查每個case的末尾是否帶有break語句,如果你試圖像其他語言那樣通過不寫break一次執行多個case的話,會直接報錯:"控制不能從一個case標簽貫穿到另外一個case標簽"。

雖然從一個case跳轉到另外一個case的語法在某些情境中能用上,然而再更多的情況下它容易引起邏輯錯誤,而它在生產環境引起的錯誤會讓你很難定位。比起未知的Exception是不是按照笨一點的規范來做更好呢?

使用goto語句,你可以在你的case語句中來回跳轉。然而當你確信你真的想這么做的話,你最好再細致的考慮一次。考慮以下的代碼:

// assume country and language are of type string
switch(country)
{
	case "America":
		CallAmericanOnlyMethod();
		goto case "Britain";
	case "France":
		language = "French";
		break;
	case "Britain":
    	language = "English";
		break;
}

雖然這樣的代碼沒有任何的Exception,但是這只是最簡單的一種情況,當你代碼里的goto語句一多,你就會發現這樣的代碼有多么的凌亂。當某個case中不需要任何語句的時候,C#允許你直接留空,這種方式讓你可以將2個或者多個case語句進行同一種處理(而不需要使用goto語句):

switch(country)
{
	case "au":
	case "uk":
	case "us":
		language = "English";
		break;
	case "at":
	case "de":
		language = "German";
		break;
}

C#的switch還有一點非常有趣的就是,case書寫的順序完全沒有任何關系,你甚至可以將default寫在第一行。因此,不允許你書寫兩個帶有同樣常量的case語句,如果你這么寫:

// assume country is of type string
const string england = "uk";
const string britain = "uk";
switch(country)
{
	case england:
	case britain: // 錯誤:switch語句包含多個具有標簽值"uk"的情況
		language = "English";
		break;
}

上面的代碼也展示了C#和C++不同的地方,在C#里允許你使用字符串作為case的常量進行判斷。

2.4.2 循環

C#提供了4種不同的循環語句(for, while, do...while和foreach),這使得你可以按照設定的條件多次執行某些語句。

for循環

C#的for循環提供了一種循環機制,根據指定的判斷條件是否為true決定是否進入下一次循環。通常格式如下:

for ([initializer]; [condition]; [iterator])
{
    statement(s);
}
  • initializer是一個表達式,會在第一次循環開始執行前執行一次(僅執行一次,通常用來定義一個循環內部使用的計數器)。
  • condition是每次循環開始前都需要檢查的一個判斷條件(當這個表達式為true時,才會再執行一次循環體)。
  • iterator會在每次循環體運算結束后執行(通常用來操作計數器)。

當condition表達式返回false時,跳出for循環。

for循環被稱為前測循環(pretest loop)因為它在執行循環體內的語句前,會先判斷condition是否成立。因此如果condition為false的話,循環體將完全不執行。

for循環在重復處理number型數據方面表現亮眼。接下來的例子演示了for循環最典型的應用。它輸出0到99共100個數字:

for (int i = 0; i < 100; i = i + 1)
{
	Console.WriteLine(i);
}

在這里,你首先定義了一個int類型變量i,並且將它初始化為0。它被用作循環計數器。接下來你測試它是否小於100,因為此時i=0小於100,結果為true,因此執行循環體里的代碼,輸出i此時的值,在控制台上顯示0。接下來,讓i自增1,然后重復整個過程。當i增加到100的時候,循環體停止。

C#提供了一種更簡單的方法,你可以用i++代替i=i+1這樣的寫法。

嵌套使用for循環也很常見,這種使用方法就像在遍歷一個矩陣的所有元素。外部循環按行進行讀取,而內部循環則按列讀取。接下來的代碼演示了如何顯示整個矩陣內容。注意到我們使用了新的方法Console.Write,它跟Console.WriteLine唯一的區別就是它往控制台上輸出字符串時不帶回車換行:

using System;
namespace Wrox
{
	class Program
	{
		static void Main()
		{
			// This loop iterates through rows
			for (int i = 0; i < 100; i+=10)
			{
				// This loop iterates through columns
				for (int j = i; j < i + 10; j++)
				{
					Console.Write($" {j}");
				}
				Console.WriteLine();
			}
		}
	}
}

雖然j是一個int類型,但是它會被自動轉換成string,所以上面的代碼執行完你會在控制台看見這樣的輸出:

0 1 2 3 4 5 6 7 8 9
10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29
30 31 32 33 34 35 36 37 38 39
40 41 42 43 44 45 46 47 48 49
50 51 52 53 54 55 56 57 58 59
60 61 62 63 64 65 66 67 68 69
70 71 72 73 74 75 76 77 78 79
80 81 82 83 84 85 86 87 88 89
90 91 92 93 94 95 96 97 98 99

雖然for循環最后的計算語句你也可以不對計數器進行操作,寫點別的你喜歡的代碼,但這不是for循環的常用方式。for循環也允許你缺省任意一個(或者全部)表達式,但這種情況下,你可以考慮使用while循環代替。

while循環

跟for循環一樣,while循環也是一個前測循環。語法很相似,但while循環只需要一個表達式:

while(condition)
	statement(s);

跟for循環不同的是,while循環更多地用於你事先不知道執行次數的情況下,去重復執行一段語句。通常在while循環體內運算到某次循環的時候,就會修改condition計算出來的值,使得整個循環體能夠結束。就好像下面這樣:

bool condition = false;
while (!condition)
{
	// This loop spins until the condition is true.
	DoSomeWork();
    // assume CheckCondition() returns a bool, it 
    // will return true at a time then end the loop.
	condition = CheckCondition(); 
}

do...while循環

do...while循環是while循環的后測(post-test)版本。這意味着使用它,你會先執行循環體,然后再檢測condition是否為true。因此do...while循環對於那些必須至少執行一次的語句很有用,如下所示:

bool condition;
do
{
	// This loop will at least execute once, even if Condition
	// is false.
	MustBeCalledAtLeastOnce();
	condition = CheckCondition();
} while (condition);

foreach循環

foreach循環允許你循環訪問一個集合里的所有元素(item)。現在你不用在意到底什么是集合(第10章的時候我們會詳細介紹),你只需要知道集合是一組對象就可以,集合里的對象必須實現IEnumerable接口。常見的集合例子有C#的數組,在System.Collections命名空間下定義的類,或者用戶自定義的集合類。讓我們看看以下的例子,你可以假設arryaOfInts是一個int類型的數組:

foreach (int temp in arrayOfInts)
{
	Console.WriteLine(temp);
}

在這里,foreach會每次都讀取一個arrayOfInts里的元素,然后將它賦值給temp,然后執行循環體,再讀取下一個,直到讀完所有元素。

一個非常重要的點是,在foreach循環里所有元素都是只讀的,你不能進行任何修改操作,像下面這樣的代碼會提示編譯錯誤:

foreach (int temp in arrayOfInts)
{
	temp++; //無法為temp賦值,它是foreach迭代變量
	Console.WriteLine(temp);
}

如果你在循環體內需要修改元素的值,你可以使用for循環代替。

2.4.3 跳轉語句

C#提供了一些語句使得你可以在程序里進行跳轉。首先就是,眾所周知的goto語句。

goto語句

goto語句允許你直接跳到任意行,只要它聲明了一個標簽(label,就是一個英文名稱然后緊跟着一個:):

goto Label1;
	Console.WriteLine("This won't be executed");
Label1:
	Console.WriteLine("Continuing execution from here");

goto語句有着一系列的限制。首先,你不能使用它跳進一個for循環的代碼塊中,也不能跳出整個class,在try...catch的finally塊里不允許存在goto語句。

聲名狼藉的goto語句在大部分情況下都是讓人深惡痛絕的。通常來說,它跟良好的面向對象編程實踐完全不搭邊。

break語句

前面你已經簡單的體驗過break語句了——用來跳出switch的case。事實上,break語句也可以用於跳出像for,foreach,while和do...while這樣的循環體。

如果你在嵌套循環里使用了break,它僅僅會跳出它所在的那一層循環體,外部循環依然可以正常使用。如果你在switch或者循環體外使用break,系統會直接提示一個編譯錯誤:"沒有要中斷或繼續的封閉循環"。

continue語句

continue語句和break語句很像,但你只能在循環里使用它。當編譯器遇到continue的時候,他會中止本次循環,直接開始下一次循環執行。

return語句

return語句通常用於結束一個class的方法,將控制權交回給方法的調用者。如果方法有返回類型,return語句必須返回該類型的值。而如果方法聲明為void,則不需要返回任何值,僅僅書寫一個return即可。

2.5 命名空間

就像早先章節討論的一樣,命名空間提供了一種組織相關class和其他types的方式。命名空間是一種邏輯上的分組方式,跟物理上的文件或者組件的組織方式不同。當你定義了一個class並將它保存到某個C#文件中的時候,你可以為它指定一個命名空間,創建一個邏輯分組,它能提示其他的開發者如何使用和關聯這些類:

using System;
namespace CustomerPhoneBookApp
{
	public struct Subscriber
	{
		// Code for struct here..
	}
}

將一個類型放置在某個命名空間下會讓它的全名很長,包括各級命名空間,並且用.進行分隔。在上面到示例里,結構體Subscriber的全名其實是:CustomerPhoneBookApp.Subscriber。使用完整名稱,可以讓你在同一個程序里使用兩個同名類型,而不會引起歧義。全名也稱為完全限定名(fully qualified name)。

你也可以將命名空間寫在其他命名空間下,形成一種多層分級的命名空間,如下所示:

namespace Wrox
{
	namespace ProCSharp
	{
		namespace Basics
		{
			class NamespaceExample
			{
				// Code for the class here..
			}
		}
	}
}

每個命名空間都是class全名的一個組成部分,從它最外部的命名空間開始,直到它本身結束。因此,ProCSharp的全名是,Wrox.ProCSharp,而類NamespaceExample的全名則是Wrox.ProCSharp.Basics.NamespaceExample。

你也可以使用這種語法來定義多層命名空間結構,如上面的例子我們可以簡寫成:

namespace Wrox.ProCSharp.Basics
{
	class NamespaceExample
	{
		// Code for the class here..
	}
}

注意在這種寫法下,不允許內部再嵌套別的長命名空間(multipart namespace),但是再一個個嵌套是允許的,這個限制在VS2019里試的時候就已經失效了,例如下面這樣寫也是允許編譯執行的:

namespace Wrox.ProCSharp.Basics
{
    namespace Test.Test2
    {
        internal class NamespaceExample
        {
            // Code for the class here..
        }
    }
}

2.5.1 using 語句

顯而易見的是,命名空間有時候會讓一個類型的全名變得特別的長,如果你需要多次使用某個類型的時候,每次都書寫完整的名稱是很煩人的。幸運的是,就像前面章節提到的,C#允許你在代碼頭使用using關鍵字聲明你需要用到的命名空間,這樣你就可以簡單的用類型的名稱進行調用:

using System;
using Wrox.ProCSharp;

就像之前很多地方提到過的,很多C#源代碼開頭都會有一個using System;這是因為大部分常用的基礎類都定義在這個System這個命名空間下。

假如兩個同名類型都在文件頭使用using語句聲明了自己所在的命名空間,那就需要對這兩個類型使用全名(或者至少是一部分名稱),以便編譯器能夠區分你在不同位置想使用的究竟是哪個類。舉個例子,假如我們有個類叫NamespaceExample,在命名空間Wrox.ProCSharp.Basics和Wrox.ProCSharp.OOP下都有這個類,在開頭我們又聲明了對這兩個命名空間的引用,此時我們就需要使用全名來告訴編譯器我們究竟要用的是哪個命名空間下的NamespaceExample類:

using Wrox.ProCSharp.OOP;
using Wrox.ProCSharp.Basics;
namespace Wrox.ProCSharp
{
	class Test
	{
		static void Main()
		{
			Basics.NamespaceExample nSEx = new Basics.NamespaceExample();
			// do something with the nSEx variable.
		}
	}
}

你所在的組織應該會願意花上一點時間來規划它們自己的命名空間架構,以便為它工作的開發者能夠快速定位他們需要的基礎功能,並且不會與第三方的類庫沖突。在后續的章節里我們會繼續討論和建議如何為您的公司/組織創建屬於您自己的命名空間體系。

2.5.2 命名空間的別名

using關鍵字的另外一個用法就是為類和命名空間指定別名。假如你需要在你的代碼里多次使用一個很長名字的類,還不得不敲全它的全名,你可以為這個類前面的那一串命名空間指定一個別名,就像這樣:

using alias = NamespaceName;

然后你就可以通過alias::className來進行定義和調用了。請看一下下面這個例子:

using System;
using Introduction = Wrox.ProCSharp.Basics;
class Program
{
	static void Main()
	{
		Introduction::NamespaceExample NSEx = new Introduction::NamespaceExample();
		Console.WriteLine(NSEx.GetNamespace());
	}
}

internal class Introduction
{
}

namespace Wrox.ProCSharp.Basics
{
	class NamespaceExample
	{
		public string GetNamespace()
		{
			return this.GetType().Namespace;
		}
	}
    
    internal class Introduction
	{
	}
}

注意::符號,這會告訴編譯器在別名里查找相應的命名空間。就算在同一個調用域內有一個叫Introduction的同名類也沒有關系。

2.6 Main()方法

跟前面提到的一樣,C#程序從Main方法開始執行,根據執行環境的需要,Main方法有幾個特別的地方:

  • 必須是static方法;
  • 可以在任何一個class里,但是方法名必須叫Main;
  • 返回值是int類型或者void類型。

雖然將Main方法聲明成public的做法很普遍,因為它要被"外部"調用,但實際上對於CLR查找入口點沒有任何影響,甚至你將它聲明成private都行。

前面我們提到的Main方法的例子里都不帶任何參數。事實上,當程序被調用的時候,你可以通過CLR給它傳遞一些命名行參數,這些參數是一個字符串數組,我們通常把它們叫做args(事實上它叫啥都行)。程序可以使用這個數組獲得從命令行傳遞過來的任何參數。

接下來的例子向你演示了如何獲取參數值並將它們輸出到控制台上:

using System;
namespace Wrox
{
	class Program
	{
		static void Main(string[] args)
		{
			for (int i = 0; i < args.Length; i++)
			{
				Console.WriteLine(args[i]);
			}
		}
	}
}

如果你想使用Visual Studio直接給應用程序傳遞參數,你可以在"項目->屬性->調試->應用程序參數"里輸入你需要的參數值,用空格分開,數量不限,如下圖所示,上面的例子將會挨個輸出:

VS設置參數

而如果你想用我們前面介紹到CLI的工具,你可以直接在dotnet run命令后面輸入參數,就像這樣:

dotnet run arg1 arg2 arg3

有時候run指令也會有一些自己的運行參數,假如你想讓你的自定義參數和命令參數區分開來,更容易分別的話,你可以在命令參數后使用--符號,如下所示:

dotnet run [command args] -- arg1 arg2 arg3

2.7 使用注釋

下一個主題是——為你的代碼添加注釋——看起來似乎很簡單,但它也可以很復雜。注釋可以對其它需要了解你代碼的人很有用。而且,你可以通過注釋來生成說明文檔以便其它人能復用你的代碼。

2.7.1 源文件中的內部注釋

就像前面說的,C#用的是傳統C類型語言的注釋,單行用//,多行用/*...*/

// This is a single-line comment
/* This comment
spans multiple lines. */

//號之后的單行內容,都會被編譯器忽略,而在/**/之間包裹的任何內容都會不會被編譯。顯而易見到是,你不能在一組/*..*/中再次插入*/,因為它會被編譯器當成是注釋的結束符。

請不要在代碼行的內容里插入注釋,因為它會讓代碼變得很難閱讀。而需要注意的是,包含在""之間的/**/則會被當成正常字符串。

string s = "/* This is just a normal string .*/";

2.7.2 XML 文檔

在前面章節提到的,C#擁有一個非常精巧(neat)的擴展:從指定格式的代碼注釋里自動生成XML格式的文檔。這些注釋都是單行注釋,但它們以三斜杠///開頭而不是通常的雙斜杠//。在這種類型的注釋里,你可以使用一些XML標簽,用來說明你代碼里的類型和成員,下面是一個編譯器能識別的標簽表:

標簽 描述
<c> 將一行注釋文本標記成代碼格式,如<c>int i = 10;</c>。
<code> 將多行注釋包含在標記成代碼塊。
<example> 標記一個代碼例子。
<exception> 記錄一個異常類(語法由編譯器驗證)。
<include> 包含其他說明文檔里的注釋(語法由編譯器驗證)。
<list> 在文檔中插入一個列表。
<para> 給出文本的結構。
<param> 標記方法的參數(語法由編譯器驗證)。
<paramref> 標識一個單詞是否為一個方法參數(語法由編譯器驗證)。
<permission> 記錄一個成員的可訪問性(語法由編譯器驗證)。
<remarks> 追加成員的描述。
<returns> 記錄方法的返回值。
<see> 提供另外一個參數的交叉引用(語法由編譯器驗證)。
<seealso> 在描述里提供一個"另外參見"小節(語法由編譯器驗證)。
<summary> 提供一個類型或成員的簡要介紹。
<typeparam> 描述一個泛型類型需要的類型參數。
<typeparamref> 提供類型參數的名稱。
<value> 描述一個屬性。

下面讓我們給MathLib.cs文件添加一些XML注釋,使得它看上去如下所示:

// MathLib.cs
namespace Wrox.MathLib
{
	///<summary>
	/// Wrox.MathLib.Calculator class.
	/// Provides a method to add two doublies.
	///</summary>
	public class Calculator
	{
		///<summary>
		/// The Add method allows us to add two doubles.
		///</summary>
		///<returns>Result of the addition (double)</returns>
		///<param name="x">First number to add</param>
		///<param name="y">Second number to add</param>
		public static double Add(double x, double y) => x + y;
	}
}

你可以在"項目->屬性->生成->XML文檔文件"里配置要生成注釋的XML文檔:

生成XML注釋

2.8 C#預處理器指令

除了大部分你已經接觸到的常見關鍵字,C#還提供了一系列的預處理指令。這些指令不會實際轉換成可執行代碼里的任何內容,但是它們會影響整個編譯過程。

舉個例子,你可以使用預處理指令,不讓編譯器編譯你程序的某些部分。你可能會發布兩個版本的程序,一個基礎版和一個企業版,企業版擁有更多的功能特性。你可以使用預處理指令,阻止編譯器編譯屬於企業版的那部分功能代碼,這樣就能簡單地生成基礎版程序。

另外一種情況是,你可能寫了一些代碼為你提供一些調試信息,但這些對用戶來說是沒有任何意義的所以你不想讓他們看到。

所有的預處理器指令都是以# symbol開頭的。

注意:C++程序員可能會將預處理器指令當做是C++中重要的組成部分。然而,C#的預處理器指令沒那么多,也不會經常使用。C#提供另外的一些機制,如自定義屬性Attributes,它跟C++里某些預處理指令的效果是一樣的。另外,C#跟C++不一樣,C#並沒有實際上提供一個另外執行的預處理器。雖然C#里的指令叫預處理器指令,但實際上它只是在編譯時處理的內容。不過C#還是把這些指令稱之為預處理器指令因為確實在你的感覺里它們干的也是預處理的事情。

接下來的幾小節我們會詳細介紹這些預處理器指令都是干啥的。

2.8.1 #define 和#undef

#define指令通常會像這么用:

#define DEBUG

這個指令告訴編譯器,有這么一個名稱的標記(Symbol)存在(如這里叫DEBUG)。這種語法有點像聲明一個變量,除了這個"變量"沒有值——但它存在。另外,這個標記不屬於你實際業務代碼的組成部分,它只是對編譯器編譯的時候有用而已,而且光定義一個名稱的話實際上也沒有任何意義。

#undef指令則相反,用來移除相應的標記:

#undef DEBUG

如果不存在這樣的標記,#undef指令則不會生效。同樣,用#define再次定義一個同名的標記也不會有任何變化。你需要將#define和#undef指令寫在實際業務代碼開始之前的位置。

#define指令經常會跟#if指令配合使用,它們是一對給力的排擋。

順帶一提,你可能注意到預處理指令和通常的C#語法不同,它不需要用;結尾。這是因為它通常只有一行,當編譯器發現當前這行是預處理指令時,它會自動認為下一行是一個新的指令,所以你不需要在結尾添加;

2.8.2 #if、#elif、#else 和#endif

這些指令告訴編譯器是否要編譯包含在其中的代碼塊。考慮以下這個方法:

int DoSomeWork(double x)
{
	// do something
	#if DEBUG
		Console.WriteLine($"x is {x}");
	#endif
}

在#if和#endif中包含了一句Console.WriteLine的方法調用。只有在前面通過#define指令定義過DEBUG這個標記,#if塊里的內容才會被編譯,Console.WriteLine方法才會被執行。當編譯器遇到#if標記時,它會去檢查其后定義的Symbol是否存在。如果不存在,編譯器就會簡單地忽略隨后的代碼直到它遇到#endif指令。最典型的應用就是,你用#define指令定義一個DEBUG標記,然后用#if DEBUG指令包含很多需要在調試時候做的事情。然后,當你完成你的代碼,需要打包的時候,你可以簡單地注釋掉你的#define指令,然后相關的調試代碼就都神奇的不見了,你的安裝包會變得很輕巧,你的用戶也不必為各種調試信息感到困擾(當然,你還需要做更多的工作確保你到代碼沒有DEBUG塊一樣能正常運行)。這項技術在C和C++編程中其實挺常見的,叫條件編譯。

#elif(=else if)指令和#else指令可以用在#if代碼塊里,提供更多直觀的含義。你也可以使用嵌套的#if塊,如下所示:

#define ENTERPRISE
#define W10
// further on in the file
#if ENTERPRISE
// do something
	#if W10
	// some code that is only relevant to enterprise
	// edition running on W10
	#endif
#elif PROFESSIONAL
// do something else
#else
// code for the leaner version
#endif

#if和#elif還支持有限的一些邏輯操作符,你可以用使用!==!=||等。一個標記如果存在,則認為是true,如果沒有定義,就認為是false,因此你也可以這樣使用:

#if W10 && (ENTERPRISE==false) // if W10 is defined but ENTERPRISE isn't

2.8.3 #warning 和 # error

另外兩個非常有用的預處理器指令是#warning和#error。當編譯器遇到這倆指令時會分別提示一個警告或者異常。考慮以下的代碼:

#if DEBUG && RELEASE
#error "You've defined DEBUG and RELEASE simultaneously!"
#endif
#warning "Don't forget to remove this line before the boss tests the code!"
Console.WriteLine("*I love this job.*");

假如編譯器遇到一個#warning指令,它會在編譯結束后,在錯誤列表窗口里顯示這個警告文本;而如果編譯器遇到一個#error指令,則會直接顯示這個錯誤文本,並且終止編譯,因此不會生成任何的IL。

你可以使用#error指令檢查自己是不是用#define語句做了什么愚蠢的事情,或者寫個#warning指令提醒自己別忘記做啥事情。

2.8.4 #region 和#endregion

#region和#endregion指令被用來標識一段代碼主要是用來干啥的,就像這樣:

#region Member Field Declarations
int x;
double d;
Currency balance;
#endregion

看起來它們沒啥用,也不會影響編譯過程。然而,這些指令能被特定的編輯器,如VS識別,它可以折疊和展開相應的代碼塊,讓你的代碼看上去更井然有序。

2.8.5 #line

#line指令可以用來改變編譯器輸出警告和錯誤時相應的文件名和行號信息。你可能不會經常用到這個指令,只有當你使用第三方的包,它在你的代碼編譯前修改了你的原始代碼文本的時候,這個指令才會顯得有用。在這種情況下,編譯器提示的行號和文件名,未必與你的源代碼一致。#line指令可以用來保存這個匹配信息。你也可以用#line default指令來恢復默認的代碼行號:

#line 164 "Core.cs" // We happen to know this is line 164 in the file Core.cs, 
// before the intermediate package mangles it.
// later on
#line default // restores default line numbering

2.8.6 #pragma

#pragma指令可以用來終止或恢復某個指定編號到編譯器警告。跟命令行選項不同的是,#pragma指令只能用在class或者method級別的實現上,它讓你能夠更細致地控制何時輸出警告。在下面的例子里我們先禁用了"field not used"(字段從未被使用過)的警告並隨后恢復了它:

#pragma warning disable 169
public class MyClass
{
	int neverUsedField;
} 
#pragma warning restore 169

2.9 C#編程規范

本章最后介紹了一些你編寫C#應用時需要遵循的規范,這些規范也是大部分C#開發者共同遵守的,如果你按照規范書寫代碼,其他人在看你代碼的時候就會更加輕松。

2.9.1 關於標識符的規則

這個小節主要說的是變量,類,方法等等內容的命名規則。注意本章提到的規則不單單是規范:它們也是C#編譯器強制要求的。

你為變量,自定義類型,類,結構體,類型成員指定了名稱,這些名字稱之為標識符(identifiers),它們是大小寫區分的,舉個例子,interestRate和InterestRate是兩個完全不同的變量,因為它們的首字母不同。以下是標識符命名的規則:

  • 必須以字母或者下划線,但不能是數字作為標識符開頭。
  • 不能使用C#的關鍵字作為標識符名。

假如你確實需要使用關鍵字作為標識符名字的話(舉個例子,如果你需要訪問其他語言寫的類,類名恰好就是C#關鍵字),你可以在關鍵字前加一個@字符,來告訴編譯器,其后緊跟的關鍵字需要當成標識符來對待(所以abstract是C#的關鍵字,而@abstract是一個有效的標識符名稱)。

最后,標識符甚至可以包含Unicode字符,通過\uXXXX來指定,其中XXXX是Unicode規定的4位16進制碼。以下是一些有效的標識符:

Name
Überfluß
_Identifier
\u005fIdentifier    

因為在Unicode中\u005f對應到就是下划線_,所以最后兩個標識符它們實質上是同名的,因此他們不能在同一個作用域里同時聲明。 雖然_開頭的標識符名稱在語法上是允許的,但是大部分情況下都不建議你聲明下划線開頭的標識符。因為它跟Microsoft為了提升代碼可讀性發布的編碼規范其實是不符合的。

2.9.2 用法約定

在很多開發語言里,有很多約定俗成的開發風格。這些風格並不是編程語言本身要求的,而是一些規范。舉個例子,變量怎么命名,相關的類、方法、函數如何使用。如果某一語言大部分的開發者遵從相同的約定,會讓他們更輕易地讀懂別人的代碼——這會使得代碼更具有可維護性。遵循什么樣的規范,主要取決於開發環境。例如,C++的程序員在開發Windows平台應用程序的時候,就會使用前綴psz或者lpsz來代表兩種字符串——如char *pszResult; char *lpszMessage;但是在Unix機器上則可能會不使用任何前綴——如char *Result; char *Message;。

本書中的C#例子在命名局部變量的時候也是不加任何前綴的:string result; string message;。

Hungarian命名法在以前很有用,通過在變量前面加上類型縮寫,使得讀代碼的人能馬上明白該變量的數據類型。但在今天的編輯器智能提示下,這種寫法就有些多余了。

盡管很多語言的開發規范都是在使用的過程中慢慢形成的,但是對於C#和所有.NET Framework開發語言來說,Microsoft編寫了一份非常詳盡的使用指南,詳細地介紹了.NET/C#的文檔規范。這意味着,在一開始,.NET程序就為開發者提供了很高的代碼交互性。這套指南也總結了一些用了20年之久的面向對象編程語言中有用的部分。通過語言討論組的研究判斷與仔細考量,這套規范最終被開發者社區欣然采納了。因此,這套規范值得遵循。

注意這只是一套開發規范而不是語言限制,然而在任何時候你都應該盡量遵循它。盡管你不照着做,編譯器也不會給你報任何編譯錯誤。一旦你決定不照着指南來,你需要有足夠令人信服的理由,你不能僅僅因為規范有點麻煩就棄之不用。當然,如果你細心地比較本書后續章節中的很多示例代碼,你可能會發現我也沒有嚴格地按照規范來做。這是因為規范主要是為了大型軟件開發而設計的,而不是簡單的示例。雖然規范非常有用,但如果你只是寫一個很小的獨立軟件包,就幾十行代碼的話,就沒必要大材小用了。在絕大多數的情況下,遵循規范會讓一些示例代碼更復雜,更難以理解要演示的某個小知識點。

一套好的代碼風格的開發規范是非常廣泛的。本章僅僅介紹最常用最重要的部分,如果你想保證你的代碼完全100%符合使用指南里的要求,你需要自己查閱Microsoft的說明文檔。

2.9.3 命名約定

你的代碼是否能夠讓別人很好的理解,很大一部分取決於你代碼里的命名——包括變量、方法、類、枚舉以及命名空間。

你的命名必須直觀明顯地體現對應項(Item)的使用目的,並且不會跟其他項(Item)的內容混淆。在.NET體系里,變量命名需要體現它的使用目的而不是代表它的數據類型。舉個例子,height就是一個很好的命名,而integerValue則不是。然而你可能會發現這個原則有時候也很難遵循。當你使用某些用戶控件的時候,你可能更願意使用confirmationDialog和chooseEmployeeListBox這樣的變量名,既描述使用目的,也提示了它的具體類型。接下來的幾小節我們會介紹在命名的時候你需要考慮的一些問題。

名稱的大小寫

在大部分情況下你需要使用Pascal命名法,每個單詞的第一個字母通常是大寫的:如EmployeeSalary,ConfirmationDialog,PlainTextEncoding。注意Namespace、Class和Members基本都遵循Pascal命名法。不建議在變量名中使用分隔線_,如 employee_salary這樣的命名。在其它語言里常量建議使用全大寫的字母,但在C#里沒有這種要求,因為全大寫的單詞並不容易閱讀——C#的常量仍然建議使用Pascal大小寫命名法:

const int MaximumLength;

另外一種建議的命名方式叫駝峰命名法(camel casing)。Camel命名法和Pascal命名法很像,除了第一個單詞的首字母是使用小寫的之外,如:employeeSalary,confirmationDialog,plainTextEncoding。在下述的3種情況里建議使用駝峰命名法:

  • 用private聲明的私有字段field,然而,這種字段更常用的方式是,前頭帶下划線;
  • 方法的參數;
  • 區分含有同樣名稱的包裝類型。例如屬性和其對應的字段:
private string employeeName;
public string EmployeeName
{
	get
	{
		return employeeName;
	}
}

當你為屬性封裝了一個相應的字段,通常你可以使用Camel命名法聲明一個private field,用Pascal命名法聲明一個public或者protected Property,所以那些外部調用的類里能看到的基本都是Pascal命名法命名的單詞(除了方法參數以外)。

你需要注意字母的大小寫,因為C#是大小寫敏感的語言,就像前面列舉的例子一樣,不同的大小寫聲明的名稱代表的是不同的項。然而,你的程序集有時候會為VB應用程序提供一些調用功能,而VB里則是大小寫無關的。因此,如果你使用僅僅只有大小寫不同的英文單詞作為標識符,你需要注意外部程序調用你的程序集時,不能同時訪問這兩個標識符,否則你無法讓VB這樣的程序正確的調用你的程序集。

命名風格的統一

請盡量保持你的命名風格是一致的。舉個例子,如果你有個方法叫ShowConfirmationDialog,那你之后需要創建類似的方法的時候,就不應該起一個像ShowDialogWarning或者WarningDialogShow的名字,正確的名字應該叫做ShowWarningDialog。

命名空間的名稱

選擇命名空間名稱的時候也要謹慎,以免與其他人用的名字重復。記住,命名空間的名稱是.NET體系分辨不同程序集里各種類型的唯一方式。因此,如果你在不同的包里使用了同樣的命名空間和類,然后又在同一個程序里同時調用了這些包,那肯定就報錯了。因為這個原因,創建一個以你公司名稱為頂級,按技術,分組,部門,逐級划分的命名空間體系是一個很好的方式。Microsoft推薦命名空間以<公司名>.<技術名>開頭,如下所示:

WeaponsOfDestructionCorp.RayGunControllers
WeaponsOfDestructionCorp.Viruses

名稱與關鍵字

需要注意你起的名字不能和任何關鍵字沖突。事實上,當你試圖在代碼里使用關鍵字作為變量名的時候,編譯器會向你提示一個語法錯誤。而且,因為你寫的類很有可能會被其他的語言調用,你最好也不要使用其他語言里用到的關鍵字作為變量名。總的來說,C++的關鍵字和C#很像,想跟C++的關鍵字混淆也不太可能,而那些經常被使用的C++特有的關鍵字通常也會以兩個下划線__開頭。跟C#一樣,C++的關鍵字也都是小寫單詞,所以只要你遵循Pascal命名法聲明你的public類和成員,他們至少都會有一個大寫字母,完全不可能跟C++關鍵字有沖突。因此,更多的問題可能來自VB程序,它擁有比C#更多的關鍵字,而且還是大小寫無關的,這意味着你就算用Pascal命名法聲明你public的類和方法也沒什么用。

你可以在https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/查閱更多的不允許聲明為公有類型的關鍵字。

2.9.4 屬性和方法的使用

class里很容易混淆的一點是屬性(Property)和方法(Method)的使用。沒有什么特別嚴格的規則來區分,總的來說,如果你覺得某個用途更像是一個變量的話,你就可以將它定義成一個屬性(第三章我們會更細致地介紹屬性)。這意味着:

  • 客戶端的代碼可以訪問到屬性的值。如果你需要提供一個只允許寫的屬性,請用方法代替。例如,定義一個SetPassword方法,而不是定義一個Write-Only的Password屬性。
  • 讀取屬性的值不應該花太多的計算步驟和時間。事實上聲明屬性的目的就是想讓它能被快速訪問。
  • 讀取屬性值的時候不應該有任何可見的或者不可預料的影響。更進一步地說,設置一個屬性值的時候,不應該直接影響跟這個屬性沒有任何直接關系的其他變量的值。設置對話框寬度很明顯會影響對話框在屏幕中的顯示效果,但它是允許的,因為寬度本來就是顯示相關的屬性。
  • 設置屬性的時候必須跟先后順序無關。特別是,因為某個屬性沒設置而導致設置另外一個屬性的時候程序報錯。舉個例子,使用一個數據庫訪問類的時候,你需要設置ConnectionString,UserName和Password。寫這個類的人需要保證你想按任何順序設置這三個屬性的值都是可行的。
  • 讀取屬性時必須返回相同的值。如果一個屬性的值可能會因為不同的情況返回不同的值,你最好聲明一個方法代替它。例如,Speed,是用來檢測智能手機的運行速度的,將它定義成一個屬性就不太合適,因為在不同情況下得到的處理速度可能不同,這里最好使用一個GetSpeed方法來代替。但是Weight和EngineSize卻是一個很好的屬性選擇,因為它們是固定的值不會發生任何改變。

如果某個項(Item)滿足以上所述的前提條件,它可能是一個合格的屬性。不然你最好還是用方法來聲明它。

2.9.5 字段的使用

字段(Field)的使用指南非常的簡單。大部分的字段都是private類型的,雖然在某些特定情況下它可能會被聲明成public類型的常量或者只讀字段以供外部調用。將一個字段聲明成public可能會對你以后擴展或者修改類功能的時候造成阻礙。

前面各小節介紹的規范能給你更好的開發體驗,你可以配合面向對象的編程風格使用他們。

最后一個有用的說明是,Microsoft在編寫.NET基礎類的時候,也非常小心地按照它發布的規范書寫代碼,所以想直觀地體驗整個規范的一種很好的方式就是——在你書寫.NET代碼的時候,看看基礎類庫的類,成員,命名空間是怎么命名,各個層次關系是如何構建的。與基礎類庫保持一致性會讓你的代碼更具有可讀性和可擴展性。

注意:新的ValueTuple類型包含public字段(Field),盡管舊的Tuple類型則使用的是屬性(Properties)。看起來Microsoft打破了它自己定義的關於fields的規范。因為Tuple的變量可以像int類型的變量一樣簡單地使用,而考慮到最重要的性能,因此在設計時決定在Value Tuples中使用public聲明的field。這個例子是想告訴你,規范並不是強制的規則必須死死遵守不能變通的,只要有正當的理由,不會引發其它異常,就可以靈活應用。第十三章我們會介紹更多關於Tuples的內容。

2.10 小結

本章主要演示了一些C#的基礎語法,包括編寫一個簡單的C#應用所需要的方方面面。我們還介紹了不少底層實現,不過這些內容對於熟悉C風格或者Javascript的開發者來說應該是駕輕就熟的。

你已經看到,C#的語法和C++還有Java非常的類似,但它們又有細微的不同。你可以在更多的地方發現C#的語法能更快更簡潔——例如,高質量的字符串處理功能。C#擁有一套強定義的類型系統,我們會在接下來的C#面向對象編程特性中詳細介紹值類型和引用類型。

參考


免責聲明!

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



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