第六講 域,屬性,方法
南京郵電學院 李建忠([email protected])
域
域(field)又稱成員變量(member variable),是c#中類不可缺少的一部分。域的類型可以是c#中任何數據類型。但對于除去string類型的其他引用類型由于在初始化時涉及到一些類的構造器的操作,我們這里將不提及,我們把這一部分內容作為“類的嵌套”放在“接口,繼承,多態(tài)與類”一講內來闡述。
域分為實例域和靜態(tài)域。實例域屬于具體的對象,為特定的對象所專有。靜態(tài)域屬于類,為所有對象所共用。c#嚴格規(guī)定實例域只能通過對象來獲取,靜態(tài)域只能通過類來獲取。例如我們有一個類型為myclass的對象myobject,myclass內的實例域instancefield(存取限制為public)只能這樣獲取:myobject. instancefield。而myclass的靜態(tài)域staticfield(存取限制為public)只能這樣獲取:myclass.staticfield。注意靜態(tài)域不能像傳統(tǒng)c++那樣通過對象獲取,也就是說myobject.staticfield的用法是錯誤的,不能通過編譯器編譯。
域的存取限制集中體現了面向對象編程的封裝原則,根據其保護級c#中類的域有五種不同的存取限制:
public可以被任意存取;
protected只可以被本類和其繼承子類存取;
internal只可以被本組合體(assembly)內所有的類存取,組合體是c#語言中類被組合后的邏輯單位和物理單位,其編譯后的文件擴展名往往是“.dll”或“.exe”。
protected internal唯一的一種組合限制修飾符,它只可以被本組合體內所有的類和這些類的繼承子類所存取。
private只可以被本類所存取。
可以看出,c#只是用internal擴展了c++原來的friend修飾符。在有必要使兩個類的某些域互相可見時,我們將這些類的域聲明為internal,然后將它們放在一個組合體內編譯即可。如果需要對它們的繼承子類也可見的話,聲明為protected internal即可。實際上這也是組合體的本來意思--將邏輯相關的類組合封裝在一起。
c#引入了readonly修飾符來表示只讀域,const來表示不變常量。顧名思義對只讀域不能進行寫操作,不變常量不能被修改,這兩者到底有什么區(qū)別呢?只讀域只能在初始化--聲明初始化或構造器初始化--的過程中賦值,其他地方不能進行對只讀域的賦值操作,否則編譯器會報錯。只讀域可以是實例域也可以是靜態(tài)域。只讀域的類型可以是c#語言的任何類型。但const修飾的常量必須在聲明的同時賦值,而且要求編譯器能夠在編譯時期計算出這個確定的值。const修飾的常量為靜態(tài)變量,不能夠為對象所獲取。const修飾的值的類型也有限制,它只能為下列類型之一(或能夠轉換為下列類型的):sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, bool, string, enum類型, 或引用類型。值得注意的是這里的引用類型,由于除去string類型外,所有的類型出去null值以外在編譯時期都不能由編譯器計算出他們的確切的值,所以我們能夠聲明為const的引用類型只能為string或值為null的其他引用類型。顯然當我們聲明一個null的常量時,我們已經失去了聲明的意義--這也可以說是c#設計的尷尬之處!
這就是說,當我們需要一個const的常量時,但它的類型又限制了它不能在編譯時期被計算出確定的值來,我們可采取將之聲明為static readonly來解決。但兩者之間還是有一點細微的差別的。看下面的兩個不同的文件:
//file1.cs
//csc /t:library file1.cs
using system;
namespace mynamespace1
{
public class myclass1
{
public static readonly int myfield = 10;
}
}
//file2.cs
//csc /r:file1.dll file2.cs
using system;
namespace mynamespace2
{
public class myclass1
{
public static void main()
{
console.writeline(mynamespace1.myclass1.myfield);
}
}
}
我們的兩個類分屬于兩個文件file1.cs 和file2.cs,并分開編譯。在文件file1.cs內的域myfield聲明為static readonly時,如果我們由于某種需要改變了myfield的值為20,我們只需重新編譯文件file1.cs為file1.dll,在執(zhí)行file2.exe時我們會得到20。但如果我們將static readonly改變?yōu)閏onst后,再改變myfield的初始化值時,我們必須重新編譯所有引用到file1.dll的文件,否則我們引用的mynamespace1.myclass1.myfield將不會如我們所愿而改變。這在大的系統(tǒng)開發(fā)過程中尤其需要注意。實際上,如果我們能夠理解const修飾的常量是在編譯時便被計算出確定的值,并代換到引用該常量的每一個地方,而readonly時在運行時才確定的量--只是在初始化后我們不希望它的值再改變,我們便能理解c#設計者們的良苦用心,我們才能徹底把握const和readonly的行為!
域的初始化是面向對象編程中一個需要特別注意的問題。c#編譯器缺省將每一個域初始化為它的默認值。簡單的說,數值類型(枚舉類型)的默認值為0或0.0。字符類型的默認值為'/x0000'。布爾類型的默認值為false。引用類型的默認值為null。結構類型的默認值為其內的所有類型都取其相應的默認值。雖然c#編譯器為每個類型都設置了默認類型,但作為面向對象的設計原則,我們還是需要對變量進行正確的初始化。實際上這也是c#推薦的做法,沒有對域進行初始化會導致編譯器發(fā)出警告信息。c#中對域進行初始化有兩個地方--聲明的同時進行初始化和在構造器內進行初始化。通過觀察編譯器輸出的中間語言,我們容易發(fā)現域的聲明初始化實際上被編譯器作為賦值語句放在了構造器的內部的最開始處執(zhí)行。實例變量初始化會被放在實例構造器內,靜態(tài)變量初始化會被放在靜態(tài)構造器內。如果我們聲明了一個靜態(tài)的變量并同時對之進行了初始化,那么編譯器將為我們構造出一個靜態(tài)構造器來把這個初始化語句變成賦值語句放在里面。而作為const修飾的常量域,從嚴格意義上講不能算作初始化語句,我們可以將它看作類似于c++中的宏代換。
屬性
屬性可以說是c#語言的一個創(chuàng)新。當然你也可以說不是。不是的原因是它背后的實現實際上還是兩個函數--一個賦值函數,一個取值函數,這從它生成的中間語言代碼可以清晰地看到。是的原因是它的的確確在語言層面實現了面向對象編程一直以來對“屬性”這一oo風格的類的特殊接口的訴求。理解屬性的設計初衷是我們用好屬性這一工具的根本。c#不提倡將域的保護級別設為public而使用戶在類外任意操作--那樣太不oo,或者具體點說太不安全!對所有有必要在類外可見的域,c#推薦采用屬性來表達。屬性提供了只讀,只寫,讀寫三種接口操作。對域這三種操作,我們必須在同一個屬性名下聲明,而不可以將它們分離,看下面的實現:
class myclass
{
private string name;
public string name
{
get { return name; }
}
public string name
{
set { name = value; }
}
}
上面這種分離name屬性實現的方法是錯誤的!我們應該這樣做:
class myclass
{
private string name;
public string name
{
get { return name; }
set { name = value; }
}
}
這里的value是c#的關鍵字,是我們進行屬性操作時的右值,例如我們有一個類型為myclass的對象myobject,我們便可以這樣進行賦值操作myobject.name=”microsoft”,也可以獲取屬性值string mystr=myobject.name。
當然屬性遠遠不止僅僅限于域的接口操作,屬性的本質還是方法,我們可以根據程序邏輯在屬性的提取或賦值時進行某些檢查,警告等額外操作,看下面的例子:
class myclass
{
private string name;
public string name
{
get { return name; }
set
{
if (value==null)
name="microsoft";
else
name=value;
}
}
}
由于屬性的方法的本質,屬性當然也有方法的種種修飾。除了方法的多參數帶來的方法重載等特性屬性不具備外, virtual, sealed, override, abstract等修飾符對屬性起著與方法同樣的作用,我們會在下面“方法”一節(jié)中詳述這些特性。屬性也有上述5種存取修飾符,但屬性的存取修飾往往為public,否則我們也就失去了屬性作為類的公共接口的意義。唯一值得注意的是三種屬性(只讀,只寫,讀寫)被c#認為是同一個屬性名,看下面的例子:
class myclass
{
protected int num=0;
public int num
{
set
{
num=value;
}
}
}
class myclassderived: myclass
{
new public int num
{
get
{
return num;
}
}
}
class test
{
public static void main()
{
myclassderived myobject = new myclassderived();
//myobject.num= 1; //錯誤 myclassderived中的屬性屏蔽了set{}的定義
((myclass)myobject).num = 1;
}
}
我們可以看到myclassderived中的屬性num-get{}屏蔽了myclass中屬性num-set{}的定義。
方法
方法又稱成員函數(member function),集中體現了類或對象的行為。方法同樣分為靜態(tài)方法和實例方法。靜態(tài)方法只可以操作靜態(tài)域,而實例方法既可以操作實例域,也可以操作靜態(tài)域--雖然這不被推薦,但在某些特殊的情況下會顯得很有用。方法也有如域一樣的5種存取修飾符--public,protected,internal,protected internal,private,它們的意義如前所述。
方法的參數是個值得特別注意的地方。方法的參數傳遞有四種類型:傳值(by value),傳址(by reference),輸出參數(by output),數組參數(by array)。傳值參數無需額外的修飾符,傳址參數需要修飾符ref,輸出參數需要修飾符out,數組參數需要修飾符params。傳值參數在方法調用過程中如果改變了參數的值,那么傳入方法的參數在方法調用完成以后并不因此而改變,而是保留原來傳入時的值。傳址參數恰恰相反,如果方法調用過程改變了參數的值,那么傳入方法的參數在調用完成以后也隨之改變。實際上從名稱上我們可以清楚地看出兩者的含義--傳值參數傳遞的是調用參數的一份拷貝,而傳址參數傳遞的是調用參數的內存地址,該參數在方法內外指向的是同一個存儲位置。看下面的例子及其輸出:
using system;
class test
{
static void swap(ref int x, ref int y)
{
int temp = x;
x = y;
y = temp;
}
static void swap(int x,int y)
{
int temp = x;
x = y;
y = temp;
}
static void main()
{
int i = 1, j = 2;
swap(ref i, ref j);
console.writeline("i = {0}, j = {1}", i, j);
swap(i,j);
console.writeline("i = {0}, j = {1}", i, j);
}
}
程序經編譯后執(zhí)行輸出:
i = 2, j = 1
i = 2, j = 1
我們可以清楚地看到兩個交換函數swap()由于參數的差別--傳值與傳址,而得到不同的調用結果。注意傳址參數的方法調用無論在聲明時還是調用時都要加上ref修飾符。
籠統(tǒng)地說傳值不會改變參數的值在有些情況下是錯誤的,我們看下面一個例子:
using system;
class element
{
public int number=10;
}
class test
{
static void change(element s)
{
s.number=100;
}
static void main()
{
element e=new element();
console.writeline(e.number);
change(e);
console.writeline(e.number);
}
}
程序經編譯后執(zhí)行輸出:
10
100
我們看到即使傳值方式仍然改變了類型為element類的對象t。但嚴格意義上講,我們是改變了對象t的域,而非對象t本身。我們再看下面的例子:
using system;
class element
{
public int number=10;
}
class test
{
static void change(element s)
{
element r=new element();
r.number=100;
s=r;
}
static void main()
{
element e=new element();
console.writeline(e.number);
change(e);
console.writeline(e.number);
}
}
程序經編譯后執(zhí)行輸出:
10
10
傳值方式根本沒有改變了類型為element類的對象t!實際上,如果我們能夠理解類這一c#中的引用類型(reference type)的特性,我們便能看出上面兩個例子差別!在傳值過程中,引用類型本身不會改變(t不會改變),但引用類型內含的域卻會改變(t.number改變了)!c#語言的引用類型有:object類型(包括系統(tǒng)內建的class類型和用戶自建的class類型--繼承自object類型),string類型,interface類型,array類型,delegate類型。它們在傳值調用中都有上面兩個例子展示的特性。
在傳值和傳址情況下,c#強制要求參數在傳入之前由用戶明確初始化,否則編譯器報錯!但我們如果有一個并不依賴于參數初值的函數,我們只是需要函數返回時得到它的值是該怎么辦呢?往往在我們的函數返回值不之一個時我們特別需要這種技巧。答案是用out修飾的輸出參數。但需要記住輸出參數與通常的函數返回值有一定的區(qū)別:函數返回值往往存在堆棧里,在返回時彈出;而輸出參數需要用戶預先制定存儲位置,也就是用戶需要提前聲明變量--當然也可以初始化。看下面的例子:
using system;
class test
{
static void resolutename(string fullname,out string firstname,out string lastname)
{
string[] strarray=fullname.split(new char[]{' '});
firstname=strarray[0];
lastname=strarray[1];
}
public static void main()
{
string myname="cornfield lee";
string myfirstname,mylastname;
resolutename(myname,out myfirstname,out mylastname);
console.writeline("my first name: {0}, my last name: {1}", myfirstname, mylastname);
}
}
程序經編譯后執(zhí)行輸出:
my first name: cornfield, my last name: lee
在函數體內所有輸出參數必須被賦值,否則編譯器報錯!out修飾符同樣應該應用在函數聲明和調用兩個地方,除了充當返回值這一特殊的功能外,out修飾符ref修飾符有很相似的地方:傳址。我們可以看出c#完全擯棄了傳統(tǒng)c/c++語言賦予程序員莫大的自由度,畢竟c#是用來開發(fā)高效的下一代網絡平臺,安全性--包括系統(tǒng)安全(系統(tǒng)結構的設計)和工程安全(避免程序員經常犯的錯誤)是它設計時的重要考慮,當然我們看到c#并沒有因為安全性而喪失多少語言的性能,這正是c#的卓越之處,“sharp”之處!
數組參數也是我們經常用到的一個地方--傳遞大量的數組集合參數。我們先看下面的例子:
using system;
class test
{
static int sum(params int[] args)
{
int s=0;
foreach(int n in args)
{
s+=n;
}
return s;
}
static void main()
{
int[] var=new int[]{1,2,3,4,5};
console.writeline("the sum:"+sum(var));
console.writeline("the sum:"+sum(10,20,30,40,50));
}
}
程序經編譯后執(zhí)行輸出:
the sum:15
the sum:150
可以看出,數組參數可以是數組如:var,也可以是能夠隱式轉化為數組的參數如:10,20,30,40,50。這為我們的程序提供了很高的擴展性。
同名方法參數的不同會導致方法出現多態(tài)現象,這又叫重載(overloading)方法。需要指出的是編譯器是在編譯時便綁定了方法和方法調用。只能通過參數的不同來重載方法,其他的不同(如返回值)不能為編譯器提供有效的重載信息。
第一等的面向對象機制為c#的方法引入了virtual,override,sealed,abstract四種修飾符。類的虛方法是可以在該類的繼承自類中改變其實現的方法,當然這種改變僅限于方法體的改變,而非方法頭(方法聲明)的改變。被子類改變的虛方法必須在方法頭加上override來表示。當一個虛方法被調用時,該類的實例--亦即對象的運行時類型(run-time type)來決定哪個方法體被調用。我們看下面的例子:
using system;
class base
{
public void f() { console.writeline("base.f"); }
public virtual void g() { console.writeline("base.g"); }
}
class derived: base
{
new public void f() { console.writeline("derived.f"); }
public override void g() { console.writeline("derived.g"); }
}
class test
{
static void main()
{
derived b = new derived();
base a = b;
a.f();
b.f();
a.g();
b.g();
}
}
程序經編譯后執(zhí)行輸出:
base.f
derived.f
derived.g
derived.g
我們可以看到class derived中f()方法的聲明采取了重寫(new)的辦法來屏蔽class base中的非虛方法f()的聲明。而g()方法就采用了覆蓋(override)的辦法來提供方法的多態(tài)機制。需要注意的是重寫(new)方法和覆蓋(override)方法的不同,從本質上講重寫方法是編譯時綁定,而覆蓋方法是運行時綁定。值得指出的是虛方法不可以是靜態(tài)方法--也就是說不可以用static和virtual同時修飾一個方法,這由它的運行時類型辨析機制所決定。override必須和virtual配合使用,當然也不能和static同時使用。
那么我們如果在一個類的繼承體系中不想再使一個虛方法被覆蓋,我們該怎樣做呢?答案是sealed override (密封覆蓋),我們將sealed和override同時修飾一個虛方法便可以達到這種目的:sealed override public void mymethod()。注意這里一定是sealed和override同時使用,也一定是密封覆蓋一個虛方法,或者一個被覆蓋(而不是密封覆蓋)了的虛方法。
抽象(abstract)方法在邏輯上類似于虛方法,只是不能像虛方法那樣被調用,而只是一個接口的聲明而非實現。抽象方法沒有類似于{…}這樣的方法實現,也不允許這樣做。抽象方法同樣不能是靜態(tài)的。含有抽象方法的類一定是抽象類(后面的專題會講到),也一定要加abstract類修飾符。但抽象類并不一定要含有抽象方法。繼承含有抽象方法的抽象類的子類必須覆蓋(override)該抽象方法使之繼續(xù)抽象(組合使用abstract override)或者使之不再抽象(直接使用(override)),看下面的例子:
//abstract.cs
// csc /t:library abstract.cs
using system;
abstract class base
{
public abstract void method();
}
abstract class child: base
{
public abstract override void method();
}
abstract class grandson: child
{
public override void method()
{
console.writeline("grandson.method");
}
}
抽象方法可以抽象一個繼承來的虛方法,我們看下面的例子:
//abstract.cs
// csc /t:library abstract.cs
using system;
class base
{
public virtual void method()
{
console.writeline("base.method");
}
}
abstract class child: base
{
public abstract override void method();
}
abstract class grandson: child
{
public override void method()
{
console.writeline("grandson.method");
}
}
歸根結底,我們抓住了運行時綁定和編譯時綁定的基本機理,我們便能看透方法呈現出的種種overload,virtual,override,sealed,abstract等形態(tài),我們才能運用好方法這一利器!
c#引入了extern修飾符來標示外部方法。外部方法是用c#以外的語言實現的方法如win32 api函數。如前所是外部方法不能是抽象方法。我們看下面的一個例子:
using system;
using system.runtime.interopservices;
class myclass
{
[dllimport("user32.dll")]
static extern int messageboxa(int hwnd, string msg,string caption, int type);
public static void main()
{
messageboxa(0, "hello, world!", "this is called from a c# app!", 0);
}
}
程序經編譯后執(zhí)行輸出: (圖片)
這里我們調用了win32 api函數int messageboxa(int hwnd, string msg,string caption, int type)。
域,屬性,方法是c#類的基本組成部分。域的關鍵是初始化問題。屬性以方法的形式封裝了域,為域的存取提供了第一等的面向對象的支持。方法的要點是參數和類型辨析--運行時和編譯時。對這些基本機制的掌握將為我們提供了一個深度進入c#面向對象編程的很好的起點。