第六讲 域,属性,方法
南京邮电学院 李建忠([email protected])
域
域(field)又称成员变量(member variable),是c#中类不可缺少的一部分。域的类型可以是c#中任何数据类型。但对于除去string类型的其他引用类型由于在初始化时涉及到一些类的构造器的操作,我们这里将不提及,我们把这一部分内容作为“类的嵌套”放在“接口,继承,多态与类”一讲内来阐述。
域分为实例域和静态域。实例域属于具体的对象,为特定的对象所专有。静态域属于类,为所有对象所共用。c#严格规定实例域只能通过对象来获取,静态域只能通过类来获取。例如我们有一个类型为myclass的对象myobject,myclass内的实例域instancefield(存取限制为public)只能这样获取:myobject. instancefield。而myclass的静态域staticfield(存取限制为public)只能这样获取:myclass.staticfield。注意静态域不能像传统c++那样通过对象获取,也就是说myobject.staticfield的用法是错误的,不能通过编译器编译。
域的存取限制集中体现了面向对象编程的封装原则,根据其保护级c#中类的域有五种不同的存取限制:
public可以被任意存取;
protected只可以被本类和其继承子类存取;
internal只可以被本组合体(assembly)内所有的类存取,组合体是c#语言中类被组合后的逻辑单位和物理单位,其编译后的文件扩展名往往是“.dll”或“.exe”。
protected internal唯一的一种组合限制修饰符,它只可以被本组合体内所有的类和这些类的继承子类所存取。
private只可以被本类所存取。
可以看出,c#只是用internal扩展了c++原来的friend修饰符。在有必要使两个类的某些域互相可见时,我们将这些类的域声明为internal,然后将它们放在一个组合体内编译即可。如果需要对它们的继承子类也可见的话,声明为protected internal即可。实际上这也是组合体的本来意思--将逻辑相关的类组合封装在一起。
c#引入了readonly修饰符来表示只读域,const来表示不变常量。顾名思义对只读域不能进行写操作,不变常量不能被修改,这两者到底有什么区别呢?只读域只能在初始化--声明初始化或构造器初始化--的过程中赋值,其他地方不能进行对只读域的赋值操作,否则编译器会报错。只读域可以是实例域也可以是静态域。只读域的类型可以是c#语言的任何类型。但const修饰的常量必须在声明的同时赋值,而且要求编译器能够在编译时期计算出这个确定的值。const修饰的常量为静态变量,不能够为对象所获取。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,在执行file2.exe时我们会得到20。但如果我们将static readonly改变为const后,再改变myfield的初始化值时,我们必须重新编译所有引用到file1.dll的文件,否则我们引用的mynamespace1.myclass1.myfield将不会如我们所愿而改变。这在大的系统开发过程中尤其需要注意。实际上,如果我们能够理解const修饰的常量是在编译时便被计算出确定的值,并代换到引用该常量的每一个地方,而readonly时在运行时才确定的量--只是在初始化后我们不希望它的值再改变,我们便能理解c#设计者们的良苦用心,我们才能彻底把握const和readonly的行为!
域的初始化是面向对象编程中一个需要特别注意的问题。c#编译器缺省将每一个域初始化为它的默认值。简单的说,数值类型(枚举类型)的默认值为0或0.0。字符类型的默认值为'/x0000'。布尔类型的默认值为false。引用类型的默认值为null。结构类型的默认值为其内的所有类型都取其相应的默认值。虽然c#编译器为每个类型都设置了默认类型,但作为面向对象的设计原则,我们还是需要对变量进行正确的初始化。实际上这也是c#推荐的做法,没有对域进行初始化会导致编译器发出警告信息。c#中对域进行初始化有两个地方--声明的同时进行初始化和在构造器内进行初始化。通过观察编译器输出的中间语言,我们容易发现域的声明初始化实际上被编译器作为赋值语句放在了构造器的内部的最开始处执行。实例变量初始化会被放在实例构造器内,静态变量初始化会被放在静态构造器内。如果我们声明了一个静态的变量并同时对之进行了初始化,那么编译器将为我们构造出一个静态构造器来把这个初始化语句变成赋值语句放在里面。而作为const修饰的常量域,从严格意义上讲不能算作初始化语句,我们可以将它看作类似于c++中的宏代换。
属性
属性可以说是c#语言的一个创新。当然你也可以说不是。不是的原因是它背后的实现实际上还是两个函数--一个赋值函数,一个取值函数,这从它生成的中间语言代码可以清晰地看到。是的原因是它的的确确在语言层面实现了面向对象编程一直以来对“属性”这一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等修饰符对属性起着与方法同样的作用,我们会在下面“方法”一节中详述这些特性。属性也有上述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),集中体现了类或对象的行为。方法同样分为静态方法和实例方法。静态方法只可以操作静态域,而实例方法既可以操作实例域,也可以操作静态域--虽然这不被推荐,但在某些特殊的情况下会显得很有用。方法也有如域一样的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);
}
}
程序经编译后执行输出:
i = 2, j = 1
i = 2, j = 1
我们可以清楚地看到两个交换函数swap()由于参数的差别--传值与传址,而得到不同的调用结果。注意传址参数的方法调用无论在声明时还是调用时都要加上ref修饰符。
笼统地说传值不会改变参数的值在有些情况下是错误的,我们看下面一个例子:
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);
}
}
程序经编译后执行输出:
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);
}
}
程序经编译后执行输出:
10
10
传值方式根本没有改变了类型为element类的对象t!实际上,如果我们能够理解类这一c#中的引用类型(reference type)的特性,我们便能看出上面两个例子差别!在传值过程中,引用类型本身不会改变(t不会改变),但引用类型内含的域却会改变(t.number改变了)!c#语言的引用类型有:object类型(包括系统内建的class类型和用户自建的class类型--继承自object类型),string类型,interface类型,array类型,delegate类型。它们在传值调用中都有上面两个例子展示的特性。
在传值和传址情况下,c#强制要求参数在传入之前由用户明确初始化,否则编译器报错!但我们如果有一个并不依赖于参数初值的函数,我们只是需要函数返回时得到它的值是该怎么办呢?往往在我们的函数返回值不之一个时我们特别需要这种技巧。答案是用out修饰的输出参数。但需要记住输出参数与通常的函数返回值有一定的区别:函数返回值往往存在堆栈里,在返回时弹出;而输出参数需要用户预先制定存储位置,也就是用户需要提前声明变量--当然也可以初始化。看下面的例子:
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);
}
}
程序经编译后执行输出:
my first name: cornfield, my last name: lee
在函数体内所有输出参数必须被赋值,否则编译器报错!out修饰符同样应该应用在函数声明和调用两个地方,除了充当返回值这一特殊的功能外,out修饰符ref修饰符有很相似的地方:传址。我们可以看出c#完全摈弃了传统c/c++语言赋予程序员莫大的自由度,毕竟c#是用来开发高效的下一代网络平台,安全性--包括系统安全(系统结构的设计)和工程安全(避免程序员经常犯的错误)是它设计时的重要考虑,当然我们看到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));
}
}
程序经编译后执行输出:
the sum:15
the sum:150
可以看出,数组参数可以是数组如:var,也可以是能够隐式转化为数组的参数如:10,20,30,40,50。这为我们的程序提供了很高的扩展性。
同名方法参数的不同会导致方法出现多态现象,这又叫重载(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();
}
}
程序经编译后执行输出:
base.f
derived.f
derived.g
derived.g
我们可以看到class derived中f()方法的声明采取了重写(new)的办法来屏蔽class base中的非虚方法f()的声明。而g()方法就采用了覆盖(override)的办法来提供方法的多态机制。需要注意的是重写(new)方法和覆盖(override)方法的不同,从本质上讲重写方法是编译时绑定,而覆盖方法是运行时绑定。值得指出的是虚方法不可以是静态方法--也就是说不可以用static和virtual同时修饰一个方法,这由它的运行时类型辨析机制所决定。override必须和virtual配合使用,当然也不能和static同时使用。
那么我们如果在一个类的继承体系中不想再使一个虚方法被覆盖,我们该怎样做呢?答案是sealed override (密封覆盖),我们将sealed和override同时修饰一个虚方法便可以达到这种目的:sealed override public void mymethod()。注意这里一定是sealed和override同时使用,也一定是密封覆盖一个虚方法,或者一个被覆盖(而不是密封覆盖)了的虚方法。
抽象(abstract)方法在逻辑上类似于虚方法,只是不能像虚方法那样被调用,而只是一个接口的声明而非实现。抽象方法没有类似于{…}这样的方法实现,也不允许这样做。抽象方法同样不能是静态的。含有抽象方法的类一定是抽象类(后面的专题会讲到),也一定要加abstract类修饰符。但抽象类并不一定要含有抽象方法。继承含有抽象方法的抽象类的子类必须覆盖(override)该抽象方法使之继续抽象(组合使用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等形态,我们才能运用好方法这一利器!
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);
}
}
程序经编译后执行输出: (图片)
这里我们调用了win32 api函数int messageboxa(int hwnd, string msg,string caption, int type)。
域,属性,方法是c#类的基本组成部分。域的关键是初始化问题。属性以方法的形式封装了域,为域的存取提供了第一等的面向对象的支持。方法的要点是参数和类型辨析--运行时和编译时。对这些基本机制的掌握将为我们提供了一个深度进入c#面向对象编程的很好的起点。