導航
第三章 Objects and Types
3.1 創建及使用類
說到這里,我們已經向你介紹了C#的部分代碼塊組成,包括變量,數據類型,控制流語句等等,你也看了部分寫在Main方法中的簡單示例。但是接下來我們將向你展示,如何使用這些元素來構建一個更長更大的完整應用程序。這個關鍵在於如何使用class,這也是本章的主題。而第四章,C#面向對象程序設計,將會更詳細的介紹繼承以及與繼承相關的特性。
注意:本章介紹的是跟class有關的基礎語法。並且,我們假設你已經非常熟悉使用class的基本原理——譬如,你了解什么是構造函數以及什么是屬性。本章僅限於介紹在C#代碼中應用這些原理。
3.2 類和結構
類(classes)和結構(struct)是你創建對象(object)的必要模版。每一個對象都包含着數據以及處理數據或者訪問數據的方法。class定義了使用這個class創建的對象實例(instance)包含了什么數據以及能做到什么(behavior)。舉個例子,你定義了一個代表客戶(customer)的class,它可能定義了一些字段,像CustomerID,FirstName,LastName和Address等等,這些字段用來標識用戶的唯一性。可能還定義了一些功能方法,用來給這些字段賦值,保存相應的數據。然后你就可以通過這個class創建一個客戶的對象實例,給它賦值,使它具有唯一性,最后調用它的各種功能方法:
class PhoneCustomer
{
public const string DayOfSendingBill = "Monday";
public int CustomerID;
public string FirstName;
public string LastName;
}
結構與類不同,因為它們不需要保存在托管堆上(類是引用類型,實際數據保存在托管堆上)。結構是值類型,通常直接保存在棧上。而且,結構不能繼承另外一個結構。
出於性能的考慮,你可以為一些較小的數據類型,創建一個結構體來包含它們。保存在棧上的數據不會被垃圾處理器(GC)回收。另外一種使用struct的場景是與原生代碼(native code)交互。結構的設計(layout)看起來跟原生數據類型很像。
從語法上來看,struct和class非常類似,只不過結構用struct定義,而類用class定義而已。假如你想讓所有的PhoneCustomer實例的內存都分配在棧上而不是分配在托管堆上的話,你可以像下面這么寫:
struct PhoneCustomerStruct
{
public const string DayOfSendingBill = "Monday";
public int CustomerID;
public string FirstName;
public string LastName;
}
class和struct都是使用new關鍵字來創建實例的,這個關鍵字創建一個對象實例並且對它進行初始化。在下面這個例子中,初始化的時候會默認地將實例的fields都清零:
var myCustomer = new PhoneCustomer(); // works for a class
var myCustomer2 = new PhoneCustomerStruct();// works for a struct
在大部分情況下,你用class要比使用struct頻繁得多,因此本章會首先着重介紹class,然后再介紹它們的區別,以便你能決定何時使用class以及何時用struct。除非特殊說明,你可以假設將同樣的代碼寫在class里跟寫在struct里運行效果是一樣的。
注意:struct和class之間一個很重要的區別是在傳遞參數時,class傳遞的是引用地址,而struct是直接傳遞的值。
3.3 類
一個類包括很多成員,可以是靜態的或者實例成員。一個靜態成員屬於這個類,而一個實例成員則屬於由這個類創建的對象(object)。通過靜態成員,這個類的所有實例共享相同的值。通過實例成員,每個對象可以擁有不同的值。我們用static關鍵字修飾靜態成員。
3.3.1 字段
字段(Fields)是與類相關的變量。你已經在前面的PhoneCustomer類里看過字段的使用了。
當你實例化一個PhoneCustomer對象,你可以通過object.FieldName的語法訪問這些字段,就像下面這樣子:
var customer1 = new PhoneCustomer();
customer1.FirstName = "Simon";
常量可以跟變量一樣與類相關聯。通過const關鍵字你可以聲明一個常量。如果常量的訪問修飾符是public的話,它也可以被外部進行訪問,如下所示:
class PhoneCustomer
{
public const string DayOfSendingBill = "Monday";
public int CustomerID;
public string FirstName;
public string LastName;
}
3.3.2 只讀字段
為了保證一個對象的字段不會被修改,你可以用readonly關鍵字聲明該字段。只讀字段只允許在構造函數的時候進行賦值,這點跟const聲明的常量不同。通過const關鍵字,編譯器將所有引用該常量的地方直接替換成常量值,因為編譯器早就知道該常量具體是什么值。而readonly字段則在調用構造函數時,才會進行賦值。跟常量不同的是,readonly字段可以是實例成員,意味着不同的類實例該字段的值也有可能不同。如果你想將一個readonly字段變成類的公共成員,你需要使用static修飾符。
假設你有一個編輯文檔的應用程序,出於版權的考慮你可能需要限制能同時打開的文檔數量。我們再假設你將一個軟件分好幾個版本進行出售,需要讓客戶可以通過升級授權解鎖更多的文檔數量。明眼人都能看出,這樣你不能簡單地在代碼里寫死一個最大文檔數,你可能需要一個字段來表示maximum number。這個字段需要從某個地方讀取值——可能存在某個文件中——當程序開始運行時。所以你的程序代碼可能像下面這樣:
public class DocumentEditor
{
private static readonly uint s_maxDocuments;
static DocumentEditor()
{
s_maxDocuments = DoSomethingToFindOutMaxNumber();
}
}
在這個案例中,這個字段被標識成static,因為這個最大文檔數需要被所有應用程序的實例共用,這也是為什么這個字段由靜態構造函數初始化。如果你使用的是readonly修飾的實例字段,你必須在實例構造函數中初始化它,例如每個文檔可以有一個創建日期,你不想讓用戶修改它(只能創建一次),這個時候你就可以將它聲明成readonly。
日期類型通常用到是System.DateTime結構體表示,下面的代碼在構造函數里初始化了一個_creationTime字段,它被聲明成readonly,整個構造函數執行完后,這個字段的值就不允許再被更改:
public class Document
{
private readonly DateTime _creationTime;
public Document()
{
_creationTime = DateTime.Now;
}
}
如果你試圖在構造函數外修改只讀字段,會引發一個編譯錯誤:
void SomeMethod()
{
s_maxDocuments = 10; // compilation error here. MaxDocuments is readonly
}
值得一提的是,你並不是必須在構造函數里給readonly字段賦值。如果你沒有主動賦值,字段將會使用其聲明類型的默認值或者你在聲明語句中使用的初始值。這個設定對static和實例readonly字段都有效。
最好不要將字段聲明為public。如果你修改了一個類的public成員,那么引用這個成員的每個類也同樣需要被修改。舉個例子,假如你想推行一個新版本的檢查最大字符串長度的變量,然后這個public字段被改成了Property。用到這個字段的現有代碼就需要因為這個改動而進行重新編譯(雖然在語法上,調用一個public字段和一個public屬性沒什么區別,調用類的代碼也不需要更改)。但如果你一開始使用的就是public屬性,那么你只需要對其getter進行修改,編譯這個類即可,其他調用類不需要重新編譯。
將字段聲明成private並且用property來訪問字段,是一個比較好的編程實踐,就像下個小節中介紹的一樣。
3.3.3 屬性
一提到屬性(Property)很容易就想到,它是一個方法(或者一組方法)的封裝,看起來和字段很像。讓我們將前面例子中的first name字段修改成一個private字段,並命名為_firstName。然后用一個叫FirstName的屬性來設置和訪問內部字段(back field)的值:
class PhoneCustomer
{
private string _firstName;
public string FirstName
{
get
{
return _firstName;
}
set
{
_firstName = value;
}
}
//...
}
get訪問器沒有任何參數並且需要返回一個跟屬性同類型的值。你不需要為set屬性顯式指定一個參數,編譯器默認它帶有一個參數,用關鍵字value進行引用。
接下來讓我們看看另外一個例子,這里我們聲明了一個叫Age的屬性,並且它包裝了一個叫age的字段。在這個例子中,age就是Age的內部變量(back variables):
private int age;
public int Age
{
get
{
return age;
}
set
{
age = value;
}
}
注意這里的命名,在C#里變量名是大小寫敏感的,所以你這么聲明在C#里沒有任何問題——對public屬性使用Pascal命名法,對private字段使用Camel命名法。在早期的.NET版本中,這種命名方式是C#組最喜歡的。最近他們改變了這種方式,取而代之的是使用一個下划線開頭的變量名稱。這種方式也可以很明顯地看出這是個字段,與局部變量不同。
注意:Microsoft的開發團隊可能使用多個不同的命名方式,沒有什么特別嚴格的規定。然而,一個團隊里的命名風格最好能統一。.NET Core團隊開始用下划線_
開頭的方式來命名private字段,你可以在https://github.com/dotnet/corefx/blob/master/Documentation/coding-guidelines/coding-style.md里找到代碼風格的說明文檔。
3.3.3.1 用表達式來寫Property訪問器
在C# 7.0,你也可以通過表達式來簡寫屬性的get/set訪問器。舉個例子,前面演示的FirstName屬性我們可以用=>
來寫,大括號{}
和return都可以省略,如下所示:
private string _firstName;
public string FirstName
{
get => _firstName;
set => _firstName = value;
}
用表達式的方式可以把幾行代碼壓縮成一行,既簡潔又省事。
3.3.3.2 自動實現屬性
如果你沒有編寫任何的get和set邏輯,也沒有定義一個內部字段,編譯器會自動幫你生成一個內部字段,然后將它與屬性關聯,這種方式我們稱之為自動實現(auto-implemented)屬性。前面的Age屬性可以像下面這樣寫:
public int Age { get; set; }
不需要你再聲明一個內部字段,編譯器會幫你自動生成。使用自動生成的屬性,你無法直接訪問它生成的內部字段,因為你不知道編譯器生成的字段叫啥。如果你只是想提供一個可讀寫的屬性,你可以使用自動實現,而不需要寫很多的內部表達式。
使用自動生成的屬性,你無法在setter中對賦值做任何有效性的判斷,因為寫不了任何判斷語句。
自動實現的屬性可以像下面這樣初始化:
public int Age { get; set; } = 42;
3.3.3.3 屬性的訪問修飾符
C#允許set和get擁有不同級別的訪問修飾符。它允許用public修飾get,而用private或者protected修飾set。這個功能主要是用來控制如何以及何時對屬性進行賦值。在接下來的代碼示例中,注意set是用private修飾的,但get可不是。在這種情況下,get直接繼承屬性的訪問級別(例子中是public)。
public string Name
{
get => _name;
private set => _name = value;
}
它們倆中至少有一個需要繼承屬性的訪問級別,如果此時你在get前面加一個protected修飾的話,就會提示一個編譯錯誤:"不能為屬性或索引器“MyClass.Name”的兩個訪問器同時指定可訪問性修飾符"。
public string Name
{
protected get => _name;
private set => _name = value;
}
你也可以為自動實現的屬性指定不同的訪問級別:
public int Age { get; private set; }
注意:有些開發者可能會關心前面各小節中提到的用屬性包裝字段的方式有沒有必要——舉個例子,通過屬性來實質上訪問內部字段,會不會有性能上的損失?因為看起來需要額外的函數調用。答案是否定的。開發人員不用擔心C#的這種編程方法。C#將會把這種使用方法編譯成IL,並由JIT實時運行。JIT會優化這部分代碼,在訪問時將它們轉換成內聯代碼(inline code)。
假設我們定義了這么一個屬性並在Main方法中調用它:
private static void Main(string[] args)
{
var myClass = new MyClass();
myClass.Age = 11;
Console.WriteLine($"{myClass.Age}");
}
public class MyClass
{
private int _Age;
public int Age { get => _Age; set => _Age = value; }
}
我們可以在ILdasm.exe中打開生成好的Main函數:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// 代碼大小 44 (0x2c)
.maxstack 2
.locals init (class Wrox.MyClass V_0)
IL_0000: nop
IL_0001: newobj instance void Wrox.MyClass::.ctor()
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldc.i4.s 11
IL_000a: callvirt instance void Wrox.MyClass::set_Age(int32)
IL_000f: nop
IL_0010: ldstr "{0}"
IL_0015: ldloc.0
IL_0016: callvirt instance int32 Wrox.MyClass::get_Age()
IL_001b: box [System.Runtime]System.Int32
IL_0020: call string [System.Runtime]System.String::Format(string,
object)
IL_0025: call void [System.Console]System.Console::WriteLine(string)
IL_002a: nop
IL_002b: ret
} // end of method Program::Main
注意IL_000a
和IL_0016
,直接執行的是set_Age(int32)和get_Age(),讓我們再看看Age屬性:
.property instance int32 Age()
{
.get instance int32 Wrox.MyClass::get_Age()
.set instance void Wrox.MyClass::set_Age(int32)
} // end of property MyClass::Age
可以看到它的getter和setter直接就被編譯成了get_Age()和set_Age(int32)函數,可以看到getter和setter只是C#為了方便我們快速開發提供的一種語言特性。
大多數時候你不需要強制改變編譯器的這種優化處理方式,但假如你需要控制編譯器是否生成優化代碼的話,你可以使用MethodImpl特性(attribute),你可以通過將一個方法標識為MethodImplOptions.NoInlining來阻止JIT將它生成內聯代碼,也可以通過標識為MethodImplOptions.AggressiveInlining來要求JIT將它生成內聯代碼。如果你想在屬性上使用這個特性,你需要直接應用到get和set訪問器上。讓我們把Age屬性做一下修改:
public int Age
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => _Age;
[MethodImpl(MethodImplOptions.NoInlining)]
set => _Age = value;
}
重新生成,可以看見setter的IL變成了這樣,注意有個cil managed noinlining
:
.method public hidebysig specialname instance void
set_Age(int32 'value') cil managed noinlining
{
// 代碼大小 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld int32 Wrox.MyClass::_Age
IL_0007: ret
} // end of method MyClass::set_Age
而默認生成內聯代碼的時候它是這樣的:
.method public hidebysig specialname instance void
set_Age(int32 'value') cil managed
{
// 代碼大小 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld int32 Wrox.MyClass::_Age
IL_0007: ret
} // end of method MyClass::set_Age
光從IL上只能看見方法名后面有個標記,方法體是一樣的,具體還是要看JIT如何將它轉換成機器碼,讓我們先看一下默認生成的機器碼:
MyClass.set_Age(Int32)
L0000: push ebp
L0001: mov ebp, esp
L0003: sub esp, 0x8
L0006: mov [ebp-0x4], ecx
L0009: mov [ebp-0x8], edx
L000c: cmp dword [0x16cec1a8], 0x0
L0013: jz L001a
L0015: call 0x71bf10f0
L001a: mov eax, [ebp-0x4]
L001d: mov edx, [ebp-0x8]
L0020: mov [eax+0x4], edx
L0023: nop
L0024: mov esp, ebp
L0026: pop ebp
L0027: ret
而cil managed noinlining
生成的則是:
MyClass.set_Age(Int32)
L0000: push ebp
L0001: mov ebp, esp
L0003: sub esp, 0x8
L0006: mov [ebp-0x4], ecx
L0009: mov [ebp-0x8], edx
L000c: cmp dword [0x16dec1a8], 0x0
L0013: jz L001a
L0015: call 0x71bf10f0
L001a: mov eax, [ebp-0x4]
L001d: mov edx, [ebp-0x8]
L0020: mov [eax+0x4], edx
L0023: nop
L0024: mov esp, ebp
L0026: pop ebp
L0027: ret
來回比對幾次,生成的內容都是一致的。因此我們得出結論,將某個方法標成MethodImplOptions.NoInlining並不會影響這個方法自身的生成。這部分並不是C#高級編程里提及的內容,不在這里做過多的展開,更多相關的知識可以查看擴展內容。需要注意的是在Debug模式下,默認是禁止JIT內聯優化的,而Release模式下則是默認執行JIT內聯優化。
3.3.3.4 只讀屬性
C#允許你創建只有getter而沒有setter的只讀屬性。如下所示:
private readonly string _name;
public string Name
{
get => _name;
}
提示:C#也支持省略getter只帶有setter的只寫屬性的方式,但這種方式不太推薦,因為在調用的時候會造成困擾。如果要支持只寫字段(例如Password),建議是用方法(如SetPassword)代替只寫屬性。
3.3.3.5 自動實現的只讀屬性
C#提供了一個簡單的語法來創建自動實現的只讀屬性,它也可以在聲明的時候直接初始化,如下所示:
public string Id { get; } = Guid.NewGuid().ToString();
在后台,編譯器會為你自動創建一個只讀字段,和一個只讀屬性,並將兩者關聯,而值的初始化語句將會被移到構造函數里執行。
只讀屬性也可以顯式地在構造函數里聲明,如下所示:
public class Person
{
public Person(string name) => Name = name;
public string Name { get; }
}
3.3.3.6 表達式屬性
從C# 6.0開始,只讀屬性可以直接使用表達式來實現,跟上面在屬性的getter里使用表達式=>
的例子不同,只讀屬性直接連get都可以省略。如下所示:
public class Person
{
public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public string FirstName { get; }
public string LastName { get; }
public string FullName => $"{FirstName} {LastName}";
}
3.3.3.7 不可變類型
如果一個類型包含可以修改的成員,那么它是一個可變類型。通過使用readonly修飾符,編譯器負責解釋類型的狀態是否發生變化,這種類型僅允許在構造函數里進行初始化。如果一個對象沒有任何可以修改的成員——它僅包含readonly成員的話——那么它就是一個不可變類型。它的內容僅可以在初始化的時候發生更改。這種類型對於多線程編程會很有用,因為多線程可以同時訪問同一個類,而它的值從來不會改變。因為它的值不變,你就不需要編寫額外的同步代碼。
一個典型的不可變類型是String類。這個類沒有定義任何可修改的成員。它的內部方法,如ToUpper之類的,總是返回一個新的字符串,而通過構造函數創建的原始字符串不會發生任何變化。
3.3.4 匿名類型
在第二章核心C#里,我們討論了通過關鍵字var來聲明隱式類型的變量。而當你使用var關鍵字搭配上new,你就創建了一個匿名類型。如下所示:
var captain = new
{
FirstName = "James",
MiddleName = "T",
LastName = "Kirk"
};
匿名類型(anonymouse type)是一個繼承於object的匿名(nameless)類。跟隱式變量一樣,通過初始化器(initializer),我們知曉上述的captain實際上是一個class,它定義了一個人名的三個組成部分。而如果你創建了另外一個對象像這樣:
var doctor = new
{
FirstName = "Leonard",
MiddleName = string.Empty,
LastName = "McCoy"
};
這倆的類型是一樣的,因為它們的屬性完全一致。
Console.WriteLine(captain.GetType()); //<>f__AnonymousType0`3[System.String,System.String,System.String]
Console.WriteLine(doctor.GetType()); //<>f__AnonymousType0`3[System.String,System.String,System.String]
匿名類型的內部成員也允許是被推斷(inferred)出來的——譬如它的成員是某個對象實例的成員。這種情況下,初始化語句可以被簡寫(abbreviated)。考慮以下代碼:
internal class Person
{
public string FirstName;
public string MiddleName;
public string LastName;
}
var person = new Person()
{
FirstName = "Leonard",
MiddleName = string.Empty,
LastName = "McCoy"
};
var captain = new
{
person.FirstName,
person.MiddleName,
person.LastName
};
編譯器會推斷captain的隱藏類型帶有三個屬性,名稱來自於person實例的各個屬性,並且在本例中person實例已經賦了值,captain的三個屬性也就有了初值。注意這么寫會報錯:
var captain = new
{
person.FirstName="John" //無效的匿名類型成員聲明符。匿名類型成員必須使用成員賦值、簡單名稱或成員訪問來聲明。
};
匿名類型的實際名稱開發者無從得知,編譯器自動"生成"了一個類型名稱,但每次生成的名稱不一定一樣,只有編譯器自己知道這次生成的叫啥。因此,你不能也不要對匿名類型的對象實例使用任何類型反射的處理,因為你將得到不一樣的結果。
3.3.5 方法
注意C#的官方技術文檔對於函數(functions)和方法(Methods)是有區分的。在C#的官方定義里,術語function不單只包含methods,還包含類或結構體的其他非數據(nondata)成員,這些成員可能包括索引、操作符、構造函數、析構函數——還有一些可能令人驚訝的屬性(Properties)。與非數據成員相對的則是數據成員:如字段、常量或者事件等等。
3.3.5.1 聲明方法
在C#里,一個方法的定義包含不少修飾符(例如方法的訪問等級),接下來是返回值類型,然后是一個方法的名稱,然后是一對小括號包着的一系列輸入參數,然后是一組大括號,中間包含着方法體,如下所示:
[modifiers] return_type MethodName([parameters])
{
// Method body
}
每個參數都包含一個類型名,以及一個可以被方法體引用的參數名。另外,如果方法帶有返回值,在代碼結束的位置,就需要使用一個return語句,返回方法指定的值類型,如下所示:
public bool IsSquare(Rectangle rect)
{
return (rect.Height == rect.Width);
}
如果方法不需要返回任何東西,則將方法聲明成void類型,因為你不能省略方法的返回值;而就算它不需要任何參數,你也需要在方法名后追加一對小括號()
。方法聲明成void類型的時候,return語句不是必須的,當程序執行到方法體}
的時候,會自動結束方法調用。
3.3.5.2 表達式體的方法
假如方法體僅僅只需要一句代碼,C#提供了一種更方便的方式來定義方法:使用=>
表達式。你不再需要書寫{}
和return
關鍵字。=>
操作符,用來區分左側的定義和右側的實現。
下面的例子和上面的IsSquare實質上是同一個方法,只不過我們用=>
表達式的語法來重新實現它。Lambda操作符的右側是方法的具體實現,不需要{}
和return關鍵字,你僅僅需要保證右側的語句返回的值類型與左側的方法定義一致即可,在下面的代碼實例里返回的是bool類型:
public bool IsSquare(Rectangle rect) => rect.Height ==
rect.Width;
3.3.5.3 調用方法
下面的例子演示了類的定義和實例化語法,以及方法定義和調用。Math類定義了示例和靜態成員:
public class Math
{
public int Value { get; set; }
public int GetSquare() => Value * Value;
public static int GetSquareOf(int x) => x * x;
public static double GetPi() => 3.14159;
}
Program類引用了這個Math類,調用了它的靜態方法,並且實例化了一個對象來調用靜態成員:
using System;
namespace MathSample
{
class Program
{
static void Main()
{
// Try calling some static functions.
Console.WriteLine($"Pi is {Math.GetPi()}");
int x = Math.GetSquareOf(5);
Console.WriteLine($"Square of 5 is {x}");
// Instantiate a Math object
var math = new Math(); // instantiate a reference type
// Call instance members
math.Value = 30;
Console.WriteLine($"Value field of math variable contains {math.Value}");
Console.WriteLine($"Square of 30 is {math.GetSquare()}");
}
}
}
執行上面這個示例你將會看到以下的結果:
Pi is 3.14159
Square of 5 is 25
Value field of math variable contains 30
Square of 30 is 900
就像你在代碼里看到的一樣,Math類包含了一個number類型的屬性,也包含了一個方法來計算它的平方值。它還包括了兩個static方法:一個用來返回π值,一個用來根據傳遞的參數計算平方值。
這個例子並不是一個良好的C#程序設計應有的風格。舉個例子,我們通常會用一個const字段來定義PI而不是特意寫一個GetPi方法,只不過使用更規范的設計就會設計到一些還沒介紹的概念。
3.3.5.4 方法重載
C#提供方法重載——帶有不同簽名(不同簽名指的是,帶有不同數量的參數或者不同類型的方法參數)的多個方法版本。想使用方法重載,最簡單的方式就是定義帶有不同類型參數的同名方法:
class ResultDisplayer
{
public void DisplayResult(string result)
{
// implementation
}
public void DisplayResult(int result)
{
// implementation
}
}
並不是只有參數類型的不同可以讓編譯器區分開相應的方法,參數數量的不同也可以,就像下面這個例子演示的一樣,一個重載方法可以調用另外一個:
class MyClass
{
public int DoSomething(int x)
{
return DoSomething(x, 10); // invoke DoSomething with two parameters
}
public int DoSomething(int x, int y)
{
// implementation
}
}
注意,如果你要使用方法重載,不同返回值的重載不是充分必要條件。而且,參數類型一致,數量一致,卻僅僅只有方法參數的名稱不同,是不行的。要么帶有不同類型的參數,要么參數數量不同,是使用方法重載的必要條件。
3.3.5.5 命名參數
調用方法的時候,通常你可以只把參數值傳遞過去,而不用特意強調參數名。然而,假如你有一個像這樣的方法:
public void MoveAndResize(int x, int y, int width, int height)
然后你像這樣調用它:
r.MoveAndResize(30, 40, 20, 40);
一眼看上去,並不知道這些具體值都代表什么含義以及這個方法具體做了哪些改變,這時候我們就可以使用命名參數了:
r.MoveAndResize(x: 30, y: 40, width: 20, height: 40);
所有的方法都可以使用命名參數,你只需要在傳遞參數值前寫上參數名,然后再加上一個:
,編譯器會自動忽略參數名和:
進行調用,就跟上面那個例子一樣,因此在編譯好的IL里沒有任何區別。C# 7.2允許你命名你需要的參數,而在早期的C#版本中,如果你需要使用命名參數,你就得保證在方法調用的時候為每個參數都寫上參數名。
使用命名參數的方式,你可以任意組織參數的書序,編譯器會進行重新組織,確保他們的正確調用。使用命名參數的另外一大好處就是我們接下來要介紹的可選(Optional)參數。
3.3.5.6 可選參數
C#允許使用可選(Optional)參數,如果你要使用這個特性,你需要為可選參數提供一個默認值,就像下面這個例子演示的一樣:
public void TestMethod(int notOptionalNumber, int optionalNumber = 42)
{
Console.WriteLine(optionalNumber + notOptionalNumber);
}
這個方法可以使用1個或者2個參數進行調用,如果只給方法傳遞了一個參數,編譯器會認為第二個參數使用它的默認值:
TestMethod(11); //相當於TestMethod(11,42);
TestMethod(11, 22);
你也可以定義多個可選參數,就像這樣:
public void TestMethod(int n, int opt1 = 11, int opt2 = 22, int opt3 = 33)
{
Console.WriteLine(n + opt1 + opt2 + opt3);
}
在這種方式下,你可以隨意地使用1,2,3或者4個參數來調用這個方法。如下所示:
TestMethod(1);
TestMethod(1, 2, 3);
第一行只傳遞了n的值,而剩下三個操作數默認使用11,22,33。而第二行則傳遞了前面3個參數,最后的opt3使用默認值33。
假如有多個可選參數,默認情況下會按順序進行調用,如果你想跳過中間的某個參數,讓它使用默認值,而僅指定其他的某些參數的話,這個時候命名參數就派上用場了。使用命名參數,你可以僅僅只傳遞你需要的可選參數,如下所示,我們僅僅將opt3設置為4,而opt1和opt2則使用的默認值11和22:
TestMethod(1, opt3: 4);
3.3.5.7 可變數量的參數
使用可選參數,你可以定義調用不同數量參數的方法。C#還提供了另外一種可變數量參數的語法,並且這種語法沒有任何版本問題。
通過將參數定義成一個數組,並且使用關鍵字params,方法就可以使用任意數量的參數進行調用,以下的例子用的是int數組:
public void AnyNumberOfArguments(params int[] data)
{
foreach (var x in data)
{
Console.WriteLine(x);
}
}
因為方法定義的參數類型是int數組,你只能傳遞int數組類型的參數,而由於我們使用了關鍵字params,你也可以選擇傳遞n個int類型的值,但不管哪種調用方式,你只能使用int類型:
AnyNumberOfArguments(1);
AnyNumberOfArguments(1, 3, 5, 7, 11, 13);
如果你需要傳遞多種類型的參數,那你可以定義一個object數組:
public void AnyNumberOfArguments(params object[] data)
{
// ...
}
然后它就可以像這樣被調用:
AnyNumberOfArguments("text", 42);
如果你的方法不止一個參數,你又想使用params定義的可變參數的話,params只能使用一次,並且它必須是最后一個參數:
Console.WriteLine(string format, params object[] arg);
現在你已經了解了方法的各個方面的內容了,接下來讓我們深入了解構造函數constructors,一種特殊類型的方法。
3.3.6 構造函數
定義一個構造函數的基礎語法和定義一個方法很類似,只是不需要任何返回值類型,如下所示:
public class MyClass
{
public MyClass()
{
}
// rest of class definition
}
不一定需要你手動為你創建的類提供構造函數,本書的很多例子就沒有提供。通常來講,如果你沒提供構造函數,編譯器會為你自動生成一個默認的無參構造函數,然后為所有的字段初始化(為引用類型賦null值,數值類型賦值為0,布爾類型賦值為false)。大部分情況下夠用了,如若不然,你需要自己寫一個構造函數。
構造函數跟其他的函數一樣,允許重載。你可以提供任意數量的重載構造函數,只要確保他們的函數簽名不同即可:
public MyClass() // zeroparameter constructor
{
// construction code
}
public MyClass(int number) // another overload
{
// construction code
}
然而,一旦你提供了帶參的構造函數,編譯器不會再自動給你生成一個無參構造函數。只有你啥也不寫的時候,編譯器才會自動生成。看一下下面這個示例:
public class MyNumber
{
private int _number;
public MyNumber(int number)
{
_number = number;
}
}
因為已經有了一個帶着一個參數的帶參構造函數,因此編譯器不會再提供一個默認的無參構造函數。如果你想通過無參函數來實例化一個MyNumber對象的話,將會得到一個編譯錯誤:
var numb = new MyNumber(); //未提供與“MyNumber.MyNumber(int)”的必需形參“number”對應的實參
需要注意的是,將構造器聲明成private或者protected也是允許的,這樣它們對於無關的類就是不可見的:
public class MyNumber
{
private int _number;
private MyNumber(int number) // another overload
{
_number = number;
}
}
上面這個例子實際上沒有定義任何public或者protected的構造器。這會使得MyNumber這個類根本無法從外部使用new關鍵字進行實例化(雖然你可能會在這個類里用public static定義一些屬性或者方法可以被外部調用)。這種寫法通常在兩種情況里會很有用:
- 如果你的類只是一個提供靜態成員或者屬性的容器類,那么它不必要也應該不被實例化。在這個應用場景中,其實你可以直接對class使用static聲明,這種用static修飾的類智能包含static成員並且無法被實例化。
- 如果你想讓類僅僅通過調用一個static成員函數進行實例化(這種對象實例化就是所謂的工廠模式)。其中一種單例模式的實現代碼如下所示:
public class Singleton
{
private static Singleton s_instance;
private int _state;
private Singleton(int state)
{
_state = state;
}
public static Singleton Instance
{
get => s_instance ?? (s_instance = new Singleton(42));
}
}
Singleton類只包含一個private構造函數,因此只能在它自己內部調用來進行實例化。為了提供該類的實例,提供了一個static修飾的屬性Instance,它返回一個私有字段s_instance。如果s_instance未被初始化(null),一個新的實例將會通過私有的構造函數進行創建,並賦值給s_instance。這里我們用到了聯結操作符??
,如果操作符左側的值為null,則會執行右側的語句(此時調用了Singleton的構造函數),並最終返回操作符右側的值。
3.3.6.1 用表達式書寫構造函數
如果構造函數的實現代碼僅僅只包含一行表達式,那么它就可以用表達式的方式簡寫:
public class Singleton
{
private static Singleton s_instance;
private int _state;
private Singleton(int state) => _state = state;
public static Singleton Instance => s_instance ?? (s_instance = new Singleton(42));
}
3.3.6.2 調用其它構造函數
使用構造函數時你可能會遇到這樣的情況,為了提供可選參數而書寫多個構造函數,就像下面這樣:
class Car
{
private string _description;
private uint _nWheels;
public Car(string description, uint nWheels)
{
_description = description;
_nWheels = nWheels;
}
public Car(string description)
{
_description = description;
_nWheels = 4;
}
// ...
}
所有的構造函數初始化的是同樣的字段,假如你有N個構造函數,那么同樣的代碼你就要寫上N次。眾所周知,重復的代碼最好提取出來放到一塊。C#提供了一種特別的構造函數語法來實現這種初始化:
class Car
{
private string _description;
private uint _nWheels;
public Car(string description, uint nWheels)
{
_description = description;
_nWheels = nWheels;
}
public Car(string description): this(description, 4)
{
}
// ...
}
在這種語境(context)里,this
關鍵字簡單的調用了最近的參數匹配的(nearest matching)構造函數。注意this
調用的內容會在實際函數體執行前先執行。假設我們這么初始化:
var myCar = new Car("Proton Persona");
在這個例子里,實際的實例化順序應該是:
- this("Proton Persona", 4) ,也就是執行Car("Proton Persona", 4)。
- Car("Proton Persona")里的其它代碼,只不過剛好這個例子里Car(string description)沒有其它代碼而已。
C#構造函數可以使用this關鍵字調用自己類里的其它的構造函數,又或者使用base關鍵字調用父類的構造函數。但需要注意的是,this或者base都只能有一個。
3.3.6.3 靜態構造函數
C#也允許使用static關鍵字來修飾一個無參構造函數。這個構造函數只會被執行一次,不像其他類型的構造函數,它只會在第一個實例創建的時候,被執行一次:
class MyClass
{
static MyClass()
{
// initialization code
}
// rest of class definition
}
使用靜態構造函數的其中一個理由可能是某些靜態成員在第一次被外部資源(external souce)調用之前需要提前進行初始化。
.NET運行時無法保證靜態構造函數會在何時進行執行(有可能在程序集加載的時候初始化),因此你最好不要在其中編寫任何需要依賴於某些特殊情況才能運行的代碼,否則不同類之間的靜態構造函數究竟按什么順序執行的將是無法預料的。可以保證的是,對於同一個類,靜態構造函數最多只會執行一次,並且是在你創建任何類引用之前最先被調用的。
注意靜態構造函數沒有任何訪問修飾符。你無法在C#代碼里顯式調用它,通常當.NET運行時加載該類時就自動調用了,所以不管是public還是private修飾符對於它來說都是毫無意義的。同樣的,靜態構造函數也沒有必要有任何參數,也因此每個類只能有一個靜態構造函數。顯而易見的是,靜態構造函數里初始化的都是靜態成員,而非其他實例成員,或者類。
注意即使你聲明了靜態構造函數,你依然可以正常聲明另外一個無參構造函數。雖然它們簽名一樣(都沒有任何參數),但是它們不會沖突,因為靜態構造函數是在一個類加載時執行的,而無參構造函數則是在某個實例創建時調用的。因此,編譯器不會對何時執行何種構造函數感到困惑。
如果你在多個類里都寫了靜態構造函數,這些靜態構造函數的執行次序是不定的。因此,你不能在靜態構造函數里編寫任何需要依賴另外一個靜態構造函數是否執行的代碼。雖然如此,如果某些靜態字段提前聲明了默認值的話,那么它們在靜態構造函數調用之前,就已經在內存里分配並存在了。
下面這個例子演示了如何使用靜態構造函數:
public static class UserPreferences
{
public static Color BackColor { get; }
static UserPreferences()
{
DateTime now = DateTime.Now;
if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday)
{
BackColor = Color.Green;
}
else
{
BackColor = Color.Red;
}
}
}
這個例子基於用戶偏好設置(大概存儲在某些配置文件里),為了簡單起見,假設僅僅只有一個配置項——BackColor,用來表示(represent)應用程序使用的背景色。因為我們不想過度的深入讀取資源文件的細節,這里我們假設這個配置項僅僅會根據日期改變顏色,周末是綠色,工作日則是紅色。雖然例子中我們只是在控制台窗口輸出內容,但就演示static構造函數是否起效而言已經足夠了:
class Program
{
static void Main()
{
Console.WriteLine($"User-preferences: BackColor is:{UserPreferences.BackColor}");
}
}
注意UserPreferences類用了static修飾符,這意味着它無法被實例化,並且它只能擁有靜態成員。它的靜態構造函數根據當天處於一周中的第幾天進行初始化。代碼里使用了.NET Framework提供的System.DateTime結構體。DateTime實現了一個static屬性Now返回當前時間。DayOfWeek則是DateTime的一個實例屬性,返回的是DayOfWeek枚舉中的某個值。這里的Color是我們自定義的一個枚舉變量,只包含了有限的幾個值:
public enum Color
{
White,
Red,
Green,
Blue,
Black
}
編譯執行前面的Main函數,你可能會看到以下結果,取決於你自己運行時是星期幾,如果是周末的話,結果就是Color Green:
User-preferences: BackColor is: Color Red
3.4 結構
到這里,你已經了解了class是如何為你的應用程序提供多樣化的對象封裝。你也了解了它們是如何存儲在托管堆上的,在數據生命周期中,為你提供盡可能多的靈活性,同時又盡量減少性能損耗。性能方面得益於托管堆的優化而沒有太大的影響。然而,在某些特殊的情況下,你可能需要一個小型的數據結構。在這種情況下,class能提供的功能太多,為了最佳的性能,你可能僅僅只想用一個簡單的結構體struct。考慮以下的例子,使用引用類型的class:
public class Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; set; }
public double Width { get; set; }
}
這段代碼定義了一個叫Dimensions的class,只是簡單的存儲了一個Item的長度和寬度。假定你編寫了一個家具整理(furniture-arranging)程序,允許用戶在電腦中重新布置家具,然后你想存儲所有新家具的尺寸。一個尺寸擁有兩個數字(長和寬),將它們作為一個整體存儲比將它們分開顯然更實用(convenient)。既沒有需要實現各種方法,也沒有必要繼承其他類,所以你當然不想將它們存儲在托管堆上,因為這需要更多的開銷。你所有的訴求,僅僅只是存儲兩個double類型的變量。
就像前面章節提到的,你僅僅只需要將你代碼里的關鍵字class改成struct即可:
public struct Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; set; }
public double Width { get; set; }
}
為struct定義功能函數就跟在class里定義方法一樣,上面你已經看到了Dimensions結構體的構造函數了,下面我們將為它添加一個屬性Diagonal,調用Math類里的Sqrt方法,計算它的對角線值:
public struct Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; set; }
public double Width { get; set; }
public double Diagonal => Math.Sqrt(Length * Length + Width * Width);
}
struct是值類型,而非引用類型。這意味着,它們要不單獨存儲在棧Stack里,要不就在托管堆Heap里的某一行(當它們作為某個實例對象的成員時),並且跟簡單數據類型(如int,bool等)擁有相同的生命周期限制:
- 結構體不支持繼承。
- 如果你沒有提供任何構造函數,編譯器同樣會為結構體創建一個無參構造函數,並初始化各個成員。就算你提供了帶參構造函數,你也可以通過無參構造函數來new一個結構體,而class不行。
- 使用結構體,你可以指定字段在內存中如何存儲(lay out),在第16章特性的時候我們會提到如何做。
因為結構體就是用來組織一組數據項的,你會發現絕大多數時候,它的字段被聲明成public。嚴格來講,這點跟Microsoft編寫的.NET指南里的fields(常量以外的字段)需要聲明成private並且封裝在public的屬性中相悖。然而,為了使結構體更加簡單,很多開發者都覺得將struct里的字段聲明成public也是可以接受的。
int類型(System.Int32)結構體就是帶有public字段的類型。新類型System.ValueType也是一個結構體,包含了多個public的字段,我們將在第13章,C#函數式編程中詳細討論。接下來的章節我們主要是探討struct和class之間的具體差異。
3.4.1 結構是值類型
雖然struct是值類型,但在大部分時候,你可以簡單地將它當成class來看。舉個例子,假如你將Dimensions定義成class,你可能會這么實例化和賦值:
var point = new Dimensions();
point.Length = 3;
point.Width = 6;
而當你將它聲明成struct時,因為struct是值類型,因此new操作符對於struct來說,跟類和其它引用類型的工作機制不同。new操作符會根據傳遞給它的參數值,調用最合適的構造函數,初始化所有字段,不用在托管堆上分配內存。事實上對於上面定義的結構體Dimensions,雖然你可以這么寫:
Dimensions point = new Dimensions();
point.Length = 3;
point.Width = 6;
但更推薦下面這兩種寫法:
Dimensions point; //直接省略new
point.Length = 3;
point.Width = 6;
或者
Dimensions point = new Dimensions(3, 6);
如果Dimensions是一個class,那么你省略new會引發一個編譯錯誤,因為point是一個空引用——尚未在托管堆上分配任何內存,因此你無法為它的任何字段進行賦值。但是對於struct來說,聲明變量的時候,就已經在棧里分配好了相應的結構體內存,所以可以開始賦值。但是,下面這樣的代碼,也會引發一個編譯錯誤,因為你使用了未初始化的變量:
Dimensions point; //struct中不能實例屬性或字段初始值設定項,我們無法像類那樣提前初始化Length和Width
double d = point.Length; //使用了未賦值的局部變量point
struct和其他數據類型遵循同樣的規則:變量使用前必須先初始化。struct的任何一個構造函數里必須對所有字段進行初始化,這樣你就可以用new操作符調用構造函數完成整個struct結構體的初始化。又或者你聲明一個struct類型變量,然后手動為所有的字段進行賦值。當然,如果一個struct聲明為某個類的成員的話,則在類的實例初始化的時候,會將該struct初始化為初始值(如果沒有其它顯式初始化struct的代碼的話)。
一個事實是結構體作為值類型來說,它可能會影響程序性能,當然這取決於你如何使用它。從好的方面來講,為結構體分配內存非常快,因為它要么作為托管堆的行內內容(take place inline)或者直接就分配在棧上。它們同樣有自己的作用域,當它超出作用域的時候,內存會被馬上回收,不用等待垃圾回收機制GC的處理。而消極的方面同樣也存在,當你將結構體作為方法參數進行傳遞,或者將它賦值給另外一個結構體的時候,整個結構體的內容都會被完整地拷貝一份。如果你的結構體非常大,這會導致不小的性能損失。所以我們再次強調,結構體是用來存儲那些足夠小的關聯數據結構的。
注意,當你將結構體作為方法參數進行傳遞時,為了避免性能損失,你可以使用ref關鍵字修飾方法形參——在這種情況下,只會將原結構體的地址傳遞到方法里進行操作,方法里對參數的任何操作都會影響到原結構體。后續的小節會更詳細地介紹這種情況。
3.4.2 只讀結構
當你將某個struct類型的屬性,提供給外部類或者程序調用的時候,調用者獲得的只是一個拷貝(copy)。對這個返回值進行任何操作僅僅影響這個拷貝值,對原始的struct沒有任何影響。這對於調用該屬性的開發者來說會感到混淆(對該屬性操作半天,完全不影響結構體的內部值)。這就是為何.NET指南里要求struct定義的值類型必須是不可變的。當然,這份指南沒有強制要求所有的值類型(僅僅針對struct),因為int,short,double...等等都不是不可變的,並且新增的ValueType也不是不可變的(immutable)。然而,大部分的struct類型都是當做不可變類型來進行實現的。
當你使用C# 7.2往后的特性的時候,編譯器允許你給struct添加一個readonly的修飾符,因此編譯器可以確保struct的不可變性。前面的Dimensions結構體可以用readonly進行修飾,因為它擁有能初始化所有值的構造函數方便你進行賦值,但是所有的屬性不能擁有set訪問器,因為修改是不允許的:
public readonly struct Dimensions
{
public Dimensions(double length, double width)
{
Length = length;
Width = width;
}
public double Length { get; }
public double Width { get; }
public double Diagonal => Math.Sqrt(Length * Length + Width * Width);
}
因為你使用了readonly修飾符,當結構體內部擁有構造函數以外的賦值入口的時候,會提示一個編譯器錯誤。同樣的通過readonly操作符,編譯器會優化相關代碼,在調用readonly結構體時,不會拷貝整個struct內容,而是傳遞struct的引用,因為它的內容是肯定不會變的。
3.4.3 結構和繼承
struct沒有設計成可繼承的,這意味着你無法繼承一個結構體。唯一例外的就是,struct和其他所有的C#類型一樣,最終都派生自System.Object。因此,struct同樣可以訪問System.Object擁有的方法,並且可以在struct里override。最簡單的例子就是重寫ToString方法。實際上每個struct都派生於System.ValueType類,而ValueType又派生於System.Object。ValueType類相對於其父類Object來說並沒有新增任何成員,只是提供了一些Object方法的override實現,以便更適合struct使用。注意你無法為struct指定另外的基類:所有的struct都直接派生自ValueType。
注意:
- 只有當structs當objects一樣使用的時候,才會用到ValueType的繼承性。而ref structs無法當成objects進行使用。本章3.4.5會更詳細的解釋ref structs,這里的ref structs說的不是在方法里用ref修飾的形參,而是另外一種結構體。
- 為了比較兩個結構體值,最好的方式是實現IEquatable<T>接口,第六章將會討論這部分。
3.4.4 結構的構造函數
你可以像定義類的構造函數那樣定義結構體的構造函數。
盡管如此,結構體總是隱式提供了用來給所有字段賦初值的無參構造函數。這意味着兩點:
- 不管你是否提供帶參的構造函數,結構體都擁有一個無參構造函數。而如果你只為某個class提供了帶參構造函數,那么該class並不存在無參構造函數。
- 你無法再顯式地為結構體指定一個無參構造函數。
public readonly struct Test
{
public Test() //編譯錯誤,結構體不能包含顯式的無參構造函數
{
}
}
順帶一提的是,你可以像為class那樣為struct提供Close或者Dispose方法。這部分內容將在第17章進行詳細講解。
3.4.5 ref 結構
struct並非總是存儲在棧里的。它們也可能存在於堆中。你可以將一個struct賦值給一個object,這將會在堆上創建一個object對象。這種行為可能會引發某些類型問題。從.NET Core 2.1開始,Span類型允許訪問棧上的內存。Span類型的拷貝必須是原子性的(atomic),但這也只能保證該類型何時存在於棧上。並且,Span類型里的字段可以用來存儲托管指針(managed pointers),而在堆上存儲這些指針會在GC啟動的時候導致整個應用程序崩潰。因此,需要保證指針類型永遠是存儲在棧上的。
而從新的C# 7.2版本開始,引用類型還是存儲在堆上,值類型雖然大部分時候存儲在棧上,但也允許在堆上存儲。為此還設計了第三種類型——只允許存在於棧上的值類型。
這種類型就是用ref關鍵字修飾的結構體,通常像下面這樣聲明:
ref struct ValueTypeOnly
{
//...
}
你可以在里面添加屬性,字段,引用類型和方法——就跟其他普通的struct一樣。
而唯一不能做的就是,將這個結構體賦值給某個對象變量——舉個例子,調用基類Object的ToString方法。這個操作會導致一個裝箱(boxing)操作,運行時在后台為struct類型創建了一個相應的引用類型,跟普通的struct不同的是,ref struct並不支持這么干。
ValueTypeOnly vt;
vt.ToString(); //無法將類型隱式地轉換成"System.ValueType"
注意:在大部分應用程序中你並不需要用到ref struct,但對於某些要求高性能的應用程序,需要盡可能地減少GC的工作量的時候,則需要用到此類型。第17章我們講Span類型的時候,會更詳細地提到ref關鍵字修飾的細節,還包括ref return和ref locals。
3.5 按值和按引用傳遞參數
讓我們假設你有一個類型叫A,並且它擁有一個int類型的屬性X。方法ChangeA接收一個A類型的參數,並且修改其屬性X的值為2,如下所示:
public static void ChangeA(A a)
{
a.X = 2;
}
Main方法里我們創建一個A的實例,並將其X值初始化為1,並調用ChangeA方法:
static void Main()
{
A a1 = new A { X = 1 };
ChangeA(a1);
Console.WriteLine($"a1.X: {a1.X}");
}
那么,最終輸出的結果會是什么呢?是1還是2呢?
答案是,取決於A到底是什么類型,你需要知道A究竟是一個class還是一個struct。
讓我們先說當A是一個struct的時候:
public struct A
{
public int X { get; set; }
}
struct作為參數時是當作值類型傳遞的。因此ChangeA方法中獲得的參數a,其實是棧里存儲的a1的一個copy。因此只有該copy發生了改變,並且當ChangeA方法結束是,參數a超出其作用域,因此它就被銷毀了,對a1沒有任何的影響,因此a1的值仍然是1。
而這跟A是class的情況時截然不同:
public class A
{
public int X { get; set; }
}
class作為參數時傳遞的是引用地址。在這種方式下,參數a存儲的是與a1一樣的地址,並且最終指向堆上的實例對象。當方法ChangeA修改a指向的X屬性時,實際上修改的就是a1.X,因為它倆指向的是同一個對象,因此調用后的X結果為2。
注意:為了避免struct和class之間的誤用,其實最好的方式是保證struct是不可外部賦值(immutable)的。盡管使用readonly的struct,你不會改變其中的任何成員狀態,也不再會遇到這種容易混淆的情況,但很多時候你並不能確保只使用不變的struct。C# 7中新出現的ValueType就是一個可變的struct,只不過ValueType里使用了public聲明的fields代替了以前的Properties,這點跟Microsoft給出的編程指南其實是相悖的,但出於元組的重要性(significance of tuples),將struct像int和float之類的基礎類型一樣使用是值得的。
3.5.1 ref 參數
你也可以將struct當做引用類型進行傳遞。通過修改ChangeA方法,我們為參數加上ref修飾符,這樣即便A是struct類型,它也會傳遞引用地址:
public static void ChangeA(ref A a)
{
a.X = 2;
}
而為了讓調用方能明確知道自己是傳遞的參數地址,在調用方法的時候,你需要顯式地寫上ref修飾符,如下所示:
static void Main()
{
A a1 = new A { X = 1 };
ChangeA(ref a1); // 注意這里要加上ref
Console.WriteLine($"a1.X: {a1.X}");
}
現在結構體也是按地址傳遞的參數了,就跟類一樣,因此調用后的結果為2。
讓我們再深入考慮一下A為class的情況,假如我們把ChangeA方法改成下面這樣子:
public static void ChangeA(A a)
{
a.X = 2;
a = new A { X = 3 };
}
那么輸出的結果會是什么呢?顯而易見的是Main方法打印的X值肯定不會是1,因為傳遞的是a1的堆的內存地址值給a,已經修改過該內存地址存儲的X值了,將a.X設置成了2,源對象a1.X訪問的是同一個地址,得到的就是變化后的2。接下來的一行代碼a = new A { X = 3 }現在在堆上創建了一個新的對象,並且a指向了新對象的內存地址。而Main方法里的變量a1則仍然指向原地址,因此a1.X值仍然是2。方法ChangeA調用完成后,參數a得到釋放,其指向的堆上的對象因為沒有任何引用,所以會被GC回收。但這對於a1來說沒有任何影響,a1.X還是2。
而如果我們在方法里添加了ref修飾符的話,這個時候我們傳遞就不再僅僅是a1指向的堆內存地址了,而是存儲了a1這個變量的棧內存地址(用C++的話來說,就是指向指針的指針,a pointer to a pointer),這個時候參數a和參數a1沒有任何倆樣,因此方法調用后,Main方法里的a1.X就變成了3:
public static void ChangeA(ref A a)
{
a.X = 2;
a = new A { X = 3 };
}
最后,最重要的是給方法傳遞的參數都必須先進行初始化,不管它是通過值傳遞還是引用傳遞的。
注意,C# 7.0開始,你可以將ref關鍵字修飾在局部變量和return返回值上,但這是另外一種新特性,我們將在第17章進行介紹。
3.5.2 out 參數
如果一個方法需要一個返回值,這個方法往往定義成該返回值的類型,並且返回相應類型的結果。那么要返回多個值的時候怎么辦,興許還是不同類型的不同值?有很多方式可以實現這個需求。一個方式是定義一個類或者結構體,並且將所需的返回值都定義成它們的成員,最終返回這個類和結構體。另外一種方式是可以使用元組類型,這個我們在第13章的時候再詳細講述。而第三種方式,則是使用out關鍵字。
接下來讓我們用一個例子來說明,示例中將會用到Int32類型的Parse方法,代碼如下所示:
string input1 = Console.ReadLine();
int result1 = int.Parse(input1);
Console.WriteLine($"result: {result1}");
其中,ReadLine方法獲取一個用戶輸入,我們假定用戶輸入的就是數字,int.Parse方法則負責將輸入的字符串轉換成int數字。
然而,用戶並不會總是如你所願的每次都輸入數字。萬一因為手誤或者其它原因,用戶輸入的不是一個正常的數字,那么Parse方法就會拋出一個異常。當然,捕獲這個異常並相應地進行處理也不是不能做到(我們將在第14章介紹異常的處理),只是這並非是一個最好的方式來處理"正常"情況,這里我們假設用戶會輸入錯誤數據就是一種"正常"的情況。
一種更好的實現方式是使用Int32類型提供的另外一種方法:TryParse。TryParse方法會返回一個bool值,用來判斷字符串的轉換是否成功。而轉換的結果(假如能成功轉換成數字的話)則會通過參數進行返回,通過out關鍵字:
public static bool TryParse(string s, out int result);
調用這個方法,out修飾的result變量不需要提前初始化,這個變量由方法內部賦值。C# 7.0以后,你甚至不用提前聲明這個變量,而是由調用方法的內部為你自動創建。跟ref關鍵字相似,方法調用的時候,傳遞參數前需要顯式使用out關鍵字:
string input2 = ReadLine();
if (int.TryParse(input2, out int result2))
{
Console.WriteLine($"result: {result2}");
}
else
{
Console.WriteLine("not a number");
}
3.5.3 in 參數
C# 7.2開始為參數新增了一個in修飾符,out修飾符允許你通過參數返回值,而in操作符則保證傳遞給方法的數據不會發生任何改變(當傳遞的是一個值類型時)。
讓我們定義一個簡單的可變struct,名字就叫AValueType好了,它帶有一個public的可變field:
struct AValueType
{
public int Data;
}
現在我們將這個struct作為in參數傳遞給CantChange方法,並且試圖為它賦值,將會提示一個編譯錯誤:
static void CantChange(in AValueType a)
{
a.Data = 43; // 錯誤:無法分配到變量'in AValueType'的成員,因為它是只讀變量
Console.WriteLine(a.Data);
}
跟ref和out不同的是,當你調用CantChange方法時,寫或者不寫in修飾符並沒有任何影響。為值類型的參數使用in修飾符,不單單可以確保該變量不會發生任何改變,編譯器還能創建更好的優化代碼。因為是只讀的值類型,編譯器選擇直接傳遞數據引用,而不是拷貝一份新的值,這樣可以減少內存的使用,變相的提高了性能。
注意:雖然in操作符主要是用在值類型上,然而你也可以將它用在引用類型上。在這種情況下,你只能修改引用類型內部成員,而不能修改引用類型自身,假定我們將上面的AValueType修改為class,那么:
private static void CantChange(in AValueType a)
{
a = new AValueType(); // 錯誤: 無法分配到變量'in AValueType',因為它是只讀變量
a.Data = 43; // 允許修改的,沒有問題
Console.WriteLine(a.Data);
}
3.6 可空類型
引用類型的變量(如class)可以是null值而值類型的變量(如structs)則不行。這在某些場景中可能是個問題,譬如將C#類型與數據庫或者XML類型進行映射時。數據庫或者XML里的int元素可能是空的,但int類型或者double類型卻無法賦值為null。
一個解決這種沖突的方式就是為數值類型也使用class進行映射(Java就是這么干的)。使用引用類型在跟數據庫之間進行映射的時候確實是允許null值的存在了,但它有一個很不好的地方,就是創建了額外的開銷(overhead)。因為用的是引用類型,GC垃圾回收就會時不時掃描它們決定是否需要回收。而值類型則不需要GC進行回收,當它們超出變量作用域時就會立即被銷毀並回收占用的內存。
C#的解決方案是:可空類型(nullable types)。可空類型是值類型,但又允許設置成null值。你只需要在類型(必須是struct)后面加上?
標識即可。這種方案唯一的開銷就是為結構體創建了一個Boolean成員,以便分辨內在的(underlying)實際的struct是否為空值。讓我們考慮以下的代碼:
int x1 = 1;
int? x2 = null;
這里x1是一個普通的int類型,而x2則是一個可空的int類型,它可以被賦null值。
因為一個普通的int沒有任何不能賦給int?的值,因此用int變量直接給int?類型的變量賦值對於編譯器來說是允許的:
int? x3 = x1;
但反過來則不行,int?不能直接賦值給int,除非你顯式的指定一個強制轉換:
int x4 = (int)x3;
當然,這種強制轉換在x3為null的時候肯定會引發一個異常。一個更好的方式是使用可空類型的兩個屬性HasValue和Value。HasValue顧名思義返回一個true或者false,取決於可空類型是否為null,而Value屬性則返回實際值。通過使用條件運算符?
,下面這么賦值將沒有任何異常:
int x5 = x3.HasValue ? x3.Value : -1;
而我們通過聯結運算符??
更是可以將賦值簡寫成下面這樣,因為x3為null的時候直接返回-1,僅當它擁有int值的時候才會直接賦值給x6:
int x6 = x3 ?? -1;
注意:通過可空類型,你可以使用所有的運算符,只要它內部實際值能進行運算即可——如+,-,*,/或者更多。你可以為所有的struct類型使用可空類型,不僅限於C#預定義的那些。在第五章泛型的時候我們將會介紹更多與可空類型有關的內容。
3.7 枚舉類型
枚舉類型是值類型,包含了一系列命名常量(named constants),譬如下面所示的Color就是一個枚舉類型:
public enum Color
{
Red,
Green,
Blue
}
你可以通過關鍵字enum定義一個枚舉類型,並且通過枚舉類型定義相關的變量,並且用某個命名常量為其賦值,如下所示:
private static void ColorSamples()
{
Color c1 = Color.Red;
Console.WriteLine(c1); // Color [Red]
}
默認的,enum類型的內在類型是int。內在類型可以轉換成其他任意的整型類型(如byte,short,int,long,無符號數或者有符號數)。枚舉類型第一個常量默認從0開始,但也可以自定義,如下所示:
public enum Color : short
{
Red = 1,
Green = 2,
Blue = 3
}
你也可以將一個數字強制轉換成相應的枚舉類型,如下所示:
Color c2 = (Color)2;
short number = (short)c2;
你也可以使用枚舉類型里的多個常量同時給一個變量進行賦值,只不過想這么做,你需要將枚舉類型里的常量都定義成bit
類型,並且對枚舉類型使用Flags
進行修飾。讓我們看看下面這個枚舉DaysOfWeek:
[Flags]
public enum DaysOfWeek
{
Monday = 0x1,
Tuesday = 0x2,
Wednesday = 0x4,
Thursday = 0x8,
Friday = 0x10,
Saturday = 0x20,
Sunday = 0x40
}
DaysOfWeek為其中的每個常量都定義了不同的值,想設置不同的bit值可以輕松地通過0x開頭的16進制數來賦值。Flags
特性則告訴編譯器創建不同的字符串來表示相應的數字——例如,你為一個DaysOfWeek類型的變量賦值為3,當Flags
起效時,你將會得到Monday, Tuesday,如下所示:
DaysOfWeek d1 = (DaysOfWeek)3;
Console.WriteLine(d1); // Monday, Tuesday
通過這樣的enum聲明,你可以為一個變量賦值多個枚舉常量,通過邏輯運算符|
進行連接,如下所示:
DaysOfWeek mondayAndWednesday = DaysOfWeek.Monday | DaysOfWeek.Wednesday;
Console.WriteLine(mondayAndWednesday); // Monday, Wednesday
通過設置不同的bit值,我們還可以將這些bit用|
組合起來代表特定值,如下所示:
[Flags]
public enum DaysOfWeek
{
Monday = 0x1,
Tuesday = 0x2,
Wednesday = 0x4,
Thursday = 0x8,
Friday = 0x10,
Saturday = 0x20,
Sunday = 0x40,
Weekend = Saturday | Sunday
Workday = 0x1f,
AllWeek = Workday | Weekend
}
其中,Weekend的值將會是Saturday的0x20通過|
運算符加上Sunday的0x40之后,變成0x60。而Workday則是從Monday到Friday之間所有日期的bit相加,所以是0x1f。最后,AllWeek則是由Workday和Weekend通過|
組合而成。
通過這種方式,我們也可以直接將DaysOfWeek.Weekend賦值給某個變量,也可以用|
組合DaysOfWeek.Saturday和DaysOfWeek.Sunday,它們的結果是一樣的。
DaysOfWeek weekend = DaysOfWeek.Saturday | DaysOfWeek.Sunday;
Console.WriteLine(weekend); // Saturday, Sunday
使用枚舉類型的過程中,Enum類可能會給你提供不小的幫助,當你需要動態的轉換某些枚舉類型時。Enum類提供了將字符串轉換成對應的枚舉常量和獲取某個枚舉類型所有常量名和值的方法。
下面這個例子將會演示如何使用Enum的TryParse方法來將一個string類型的字符串轉換成相應的Color枚舉:
if (Enum.TryParse<Color>("Red", out red))
{
Console.WriteLine($"successfully parsed {red}");
}
注意:Enum.TryParse<T>()是一個泛型方法,其中T是泛型參數類型。T需要在方法調用時進行定義,更多有關泛型方法的細節我們將在第五章進行討論。
Enum.GetNames方法則會返回一個帶有所有常量名稱的string[]數組:
foreach (var day in Enum.GetNames(typeof(Color)))
{
Console.WriteLine(day);
}
執行后你將會看到:
Red
Green
Blue
如果想獲取枚舉類型的所有常量的值的話,你可以使用Enum.GetValues方法。這個方法會返回一個Array,注意你還需要進行強制轉換才能獲取相應的數值,就像下面這樣用short類型,輸出的就是0,1,2:
foreach (short val in Enum.GetValues(typeof(Color)))
{
Console.WriteLine(val);
}
而如果你使用的是var修飾val的話,默認輸出的內容與Enum.GetNames完全一樣。
3.8 部分類
partial關鍵字讓你能夠講class,struct,method或者interface分別存到不同文件中。典型的例子就是,某些類型的代碼生成器只用來生成class類中的某些特定部分,因此如果能見一個class分隔成多個不同文件進行存儲將會很有用。讓我們假設你想為某個工具自動生成的class追加一些功能,假如這個工具重啟了,那么你所做的改動自然就丟失了。這個時候partial關鍵字就很有用了,它可以將這個class分隔成兩個文件,一個是工具自動生成的文件,在另外一個文件里編輯你想追加的內容,這樣你就可以隨心修改不怕丟失了。
使用partial關鍵字,你只需要在class,struct或者interface前面寫上partial修飾符即可。在接下來的例子里,SampleClass類被分別存在兩個文件SampleClassAutogenerated.cs和SampleClass.cs里:
//SampleClassAutogenerated.cs
partial class SampleClass
{
public void MethodOne() { }
}
//SampleClass.cs
partial class SampleClass
{
public void MethodTwo() { }
}
當項目編譯的時候,會生成一個帶有MethodOne和MethodTwo方法的SampleClass類型。
假如你為某個partial類使用了以下任何一種修飾符,那么其他的partial類的修飾符也必須是完全一樣的(他們本來就是同一個類,只不過存在不同地方而已):
- public
- private
- protected
- internal
- abstract
- sealed
- new
- generic constraints
在class上使用嵌套partial也是允許的。特性,XML注釋,接口,泛型類型參數屬性以及成員都會被合並到一起,當不同的partial類編譯成一個的時候。考慮以下的例子:
// SampleClassAutogenerated.cs
[CustomAttribute]
partial class SampleClass: SampleBaseClass, ISampleClass
{
public void MethodOne() { }
}
// SampleClass.cs
[AnotherAttribute]
partial class SampleClass: IOtherSampleClass
{
public void MethodTwo() { }
}
實際上它等價於:
[CustomAttribute]
[AnotherAttribute]
partial class SampleClass: SampleBaseClass, ISampleClass, IOtherSampleClass
{
public void MethodOne() { }
public void MethodTwo() { }
}
注意:雖然將一個大的class分隔成很多文件並且可能有不同的開發者各自維護不同的部分,這看起來很誘人,但實際上,partial關鍵字並不是設計來干這個的。在這種情況下,更建議你直接將這個大class直接分成若干個小的class,保證每個class只完成一類功能(just for one purpose)。
partial類同樣可以包含partial方法。當生成的代碼需要調用某個事實上不存在的方法時候這點極其有用。當程序員繼承該partial類的時候,可以決定是否為partial方法創建一個自己的實現或者啥也不干。看一下下面這個示例:
//SampleClassAutogenerated.cs
partial class SampleClass
{
public void MethodOne()
{
APartialMethod();
}
//分部方法不能有任何修飾符,所以原書例子中這里的public不能有
//另外partial后面只能跟class,struct,interface或者void
//public partial void APartialMethod();
partial void APartialMethod();
}
這里的方法MethodOne調用了一個partial方法APartialMethod,因為是用partial修飾的,所以方法不一定需要在這個類里實現。而如果編譯的時候發現這個方法沒有任何實現,編譯器就會從MethodOne中移除對APartialMethod這個方法的調用。
而假如你在其他partial類中實現了這個partial方法,如下所示:
// SampleClass.cs
partial class SampleClass
{
/*原書例子,錯誤同上,而且這里甚至連partial都沒有寫
public void APartialMethod()
{
// implementation of APartialMethod
}
*/
partial void APartialMethod(); //最終我們必須在這里加上這一句,原書中並沒有
//注釋掉上面那句會報錯:沒有為分部方法“SampleClass.APartialMethod()”的實現聲明找到定義聲明
partial void APartialMethod()
{
// implementation of APartialMethod
// 這里的實現我隨便寫了點,通過ILdasm你可以看到確實合並了這倆方法,但你寫成兩個文件的時候,
// 互相之間的成員,方法體都不可見,根本調用不了,還得重復聲明
// 寫在同一個文件里的話,其實就是一個占位符的用處,感覺這個特性意義不大...
}
}
編譯器就會為MethodOne方法創建一個相應的APartialMethod調用。
注意partial方法的返回類型必須是void類型,否則每個partial方法都返回值的話,編譯器就無法正確判斷究竟哪個是對的,也無法移除相應的調用入口了。
3.9 擴展方法
我們有很多種方法可以用來擴展一個類,譬如說第四章我們將談到的繼承,就是一個為類提供更多功能的好方式。而擴展方法(Extension Method)則是另外一種為某個類追加新功能的方案(這種方法甚至可以做到繼承無法達成的部分,譬如當一個類被聲明成sealed時,它就無法被繼承)。
注意:擴展方法甚至可以用來擴展接口,通過這種方式你可以為所有實現了該接口的類統一追加新功能。這部分我們同樣會在第四章講到。
擴展方法是static方法,看起來像是所屬類中的一部分,事實上,編譯后它並不存在這個類中,但這點不太重要。
讓我們先看一個簡單的例子。假如你想為string擴展一個功能,讓每個string都可以通過一個叫GetWordCount的方法,獲取string里有多少個單詞。這里我們用到Split()方法,它會按空格對字符串進行划分。我們可以這么寫:
public static class StringExtension
{
public static int GetWordCount(this string s) => s.Split().Length;
}
你可以注意到第一個參數里的this
關鍵字,這個關鍵字聲明了我們要擴展的是隨后的string類型。
注意雖然這里的GetWordCount是static方法,但是跟平常我們通過'類名.方法名'調用不同的是,我們可以這么調用:
string fox = "the quick brown fox jumped over the lazy dogs down " +
"9876543210 times";
int wordCount = fox.GetWordCount();// 直接調用GetWordCount
Console.WriteLine($"{wordCount} words");
事實上在后台,編譯器是這么執行的:
int wordCount = StringExtension.GetWordCount(fox);
比起編譯器實際執行的語法,上面那種寫法是不是看起來更加直觀。這種寫法還有一個好處就是你可以隨時更換擴展類里的方法實現,只需要編譯器重新編譯一下即可。
那么編譯器是怎么判斷指定類型的擴展方法是哪個的呢?不僅僅需要this
關鍵字,還需要擴展類的命名空間,以便編譯器知道打開哪個擴展類。只有當你聲明了擴展類的命名空間時,編譯器才能在相應的空間下找到相應的擴展類和擴展方法。萬一除了擴展方法以外,被擴展的類型自己就有跟擴展方法同名的實例方法,編譯器會優先調用類型自身定義的實例方法,而舍棄擴展方法。
當你在不同的擴展類里定義了同一個類型的同名擴展方法的時候,並且你又同時引用了這些擴展類的命名空間,這個時候編譯器就會拋出一個錯誤,表示它不知道應該應用哪個擴展方法好。然而,如果調用方法的代碼剛好跟某個擴展類在同一個命名空間下的話,編譯器就會優先調用這個命名空間下的擴展方法。
注意:LINQ使用了很多擴展方法,我們將在第12章介紹它。
3.10 Object 類
就像我們前面提到的,.NET所有的類都派生自System.Object。事實上,當你定義一個類又沒有指定任何父類的時候,編譯器自動認為這個類派生自System.Object。因為繼承不是本章節要講的內容,但你所看到的所有例子里的類實際上就是派生自System.Object。結構體struct則是直接繼承自System.ValueType,而System.ValueType又繼承自System.Object,所以struct最終也派生自System.Object。
這一點非常重要——不僅方法,屬性又或者其他一切你定義的內容,都可以訪問到Object類里定義的public或者protected成員。這些方法同樣適用於那些不是你定義的類。
下面總結了Object類中的方法和它們的用途:
- ToString:一個相當基礎,快捷,簡單的字符串顯示方法。使用這個方法,你可以快速的了解某個對象的內容,譬如在調試的時候。關於如何格式化數據它幾乎沒有提供太多的選擇。舉個例子,日期類型的數據可能會有各種各樣的格式,但是DateTime.ToString方法在這方面沒有給你提供任何選擇。如果你需要一個更豐富的字符串展示——例如,當你考慮根據指定的格式或者不同的文化(或者區域)顯示不同風格的日期的時候——你可能需要實現IFormattable接口(第九章將會涉及這部分內容)。
- GetHashCode:如果對象存儲在某些特定的數據結構,譬如圖,又或者哈希表和字典中時,它需要通過創建這些對象的類來決定在數據結構中的何處存儲這些對象。假如你想讓你的類作為字典中的鍵值,你需要重寫該類的GetHashCode方法。我們將在第十章的時候介紹這部分,因為如何實現這個方法的重載有一些相當嚴格的要求。
- Equals(全版本)和ReferenceEquals:你可能已經注意到,在.NET中存在三種不同的方法用來比較兩個對象是否相等,包括操作符
==
在內,他們仨有一些細微的區別。此外,如何重寫帶有一個參數版本的Equals虛方法還存在一定的限制,因為System.Collections命名空間里的基類會調用這個方法,因此它需要能得出一個確定的結果。第六章的時候你將會探索這些方法的使用。 - Finalize:第十七章將會包括這部分內容,這個C#方法非常像C++風格的析構函數(destructors)。當GC垃圾回收需要清理無用的引用對象時被調用。Object類定義了Finalize方法但是里面沒有任何實現,所以GC會忽略這一部分。通常你可以在一個對象引用非托管資源的時候,重寫此方法,因為默認GC只會處理托管資源,而非托管資源如何釋放取決於你提供的Finalize方法。
- GetType:這個方法返回一個派生自System.Type的類實例,因此它可以提供更加廣泛具體的信息,你調用GetType方法的對象將會成為這個實例中的一個成員,方法還會提供給你其它信息,譬如基類,方法名,屬性等等。System.Type還是.NET反射技術的入口。第十六章將會詳細介紹這部分。
- MemberwiseClone:唯一一個本書沒有詳細介紹的方法。這是因為這個方法從概念上看就相當簡單。它只是單純地拷貝一個對象,並返回拷貝后的引用(對於值類型,則裝箱后返回裝箱對象的引用)。注意這個拷貝只是淺拷貝,這意味着它只拷貝了目標class中所有的值類型,假如目標class中還含有任何引用,那么它只拷貝引用,而不拷貝引用指向的實際對象。這個方法是protected修飾的,因此並不能用來拷貝外部對象。並且它也不是virtual方法,所以你也無法重寫它。
3.11 小結
本章你體驗了聲明和操作對象的C#語法。你也了解了如何聲明靜態和實例字段,屬性,方法還有構造函數。你還了解了C# 7.0新增的一部分新特性,譬如用表達式來創建各種類成員(構造函數,屬性訪問器,輸出變量等)。
你也了解了C#里所有的類型都派生自System.Object,這意味着所有的類型都可以使用Object類里定義的常用方法,如ToString等。
本章多次提到了繼承,你也部分體驗了它的實現,接口繼承和其他面向對象相關的內容我們將在下一章進行講解。