《叩开C#之门》系列之四
四、定义C#的类
既然类类型是C#中最重要、最常见的类型,因此它是我要介绍的重点,实际上,C#中的许多特性都可以通过类类型来体现。
前面已经介绍,一个类对象中,主要包括字段、属性和方法。不过除此之外,在类类型中还可以定义嵌套类,也可以定义一个常量。
对于一个没有任何编程知识的初学者而言,也许还应介绍一下常量与变量。不过从它们的名字就可以非常清晰地辨明二者的区别,常量其值是不可改变的,而变量的值则可以修改,除非该变量是只读的(如设置为readonly)。
最好的常量的例子就是圆周率值,这个值当然是不变化的,如果保留小数点后七位,其值为3.1415926。然而如果我们要频繁使用该值,则输入这么多数字自然不是好的选择,何况一旦用户要求圆周率更加精确,需要保留更多的小数位,要修改起来就非常困难了。因此我们需要定义一个常量:
const PI = 3.1415926;
此时pi就代表了3.1415926,要使用圆周率,直接取pi的值即可:
square = PI * radius;
注意上面的表达式,其中PI是常量,在定义它时,使用了const关键字;而square和radius则为变量,定义如下:
double square, radius;
一旦定义了PI为常量,那么该类对象被建立之后,就不能修改了,而变量是可以修改的。如下的代码是错误的:
PI = 3.1415926535897;
square = PI * radius;
而下面的两行代码则是正确的:
radius = 2.5;
square = PI * radius;
类的字段其实也是变量。如系列三中的类User,就包含有字段m_name,m_password,m_tryCounter。它们的类型分别为string,string,int。字段仍然可以利用public,internal,protected,private来修饰它。不过,我建议如非特殊情况,不要将字段修饰为public。因为,根据”对象封装”的原则,应尽量避免将一个类型的字段以公有方式提供给外部。毕竟,对于字段而言,对象对它的控制非常弱,一旦公开在外,则调用者可以比较容易的对其进行操作,尤其是写操作,从而可能会导致错误。例如,我们为前面定义的User类增加一个age(年龄)字段,假如我将其定义为public字段,如下所示:
public int Age;
那么调用者可能会将Age的值设为负数:
user.Age = -5;
对于字段的定义而言,并不能判断这样一种不合常理的操作,因为我们对字段的写操作的控制无能为力。
大家可以看到,这里所谓的字段值,其实可以对应于前面所讲的对象的属性。例如姓名,年龄,就是一个用户的属性。如果字段不能设置为public,那么调用者又如何访问它们呢?答案就是使用C#类中的property(属性)。
所谓“属性”,很大程度可以看作是对“字段”的一种封装,它利用一种被称为“get/set访问器”分别控制对字段的读写操作,并暴露一个属性值,如Age属性:
private int m_age;
public int Age
{
get {return m_age;}
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException("Age must be greater than or equal to 0");
}
m_age = value;
}
}
上面的代码中,throw语句的作用是抛出一个异常,我们暂时可以不去理会它,而是将注意力放到get和set访问器上。首先,我们定义了一个私有字段m_age,然后再定义一个公共属性Age。在该属性中,get返回私有字段的值m_age,而在set中,首先会判断value的值,如果小于0,那么这个值是非法的,就将抛出一个异常,停止往下执行,并告诉你对Age值的设置错误了。当然,我们也可以为value值设置更严格的要求,例如不允许value大于150。至少人的年龄现在没有超过150岁的吧。
也许会有人疑问value究竟是什么?它其实是C#中提供的关键字,代表的就是你赋给该属性的真正的值,例如:
user.Age = 30;
此时是对Age属性赋值,.Net会执行set访问器,而value值就是30。然后判断30是否小于0。显然不符合条件,不会抛出异常,继续执行,将value值30赋给字段m_age。为什么要赋给m_age呢?让我们再看看get访问器,它其实就是一个读操作,返回的值是什么?对了,就是字段m_age,如下所示:
user.Age = 30; //set操作,将字段m_age设置为30;
Console.WriteLine(“User’s Age is {0}.”, user.Age); //get操作,将m_age的值取出;
此时就会在控制台下显示:
User’s Age is 30.
此外,对于一些特殊的要求,我们在将字段封装为属性时,可以只设置它的get访问器或者set访问器,这样这个属性就是只读属性,或者只写属性了。这样显然更有利于对象的封装。毕竟对于公共字段而言,我们最能可以控制它为只读(设置为readonly),却无法设置为只写。
从上可以看到,实际上属性就是对字段进行一次封装。通过这个封装,使我们对字段m_age的读写都具有了控制功能,至少现在的Age属性能够控制赋值为负数的情况了。这就是属性的好处。
在C# 2.0中,除了可以对整个属性设置public等访问修饰符外,对内部的get/set访问器同样可以设置访问修饰符,当然它要受到一定的限制。由于有些限制和接口、重写有关,我暂时不会介绍,在这里,我仅介绍访问器和属性的访问修饰符冲突问题。
1、如果整个属性被设置为public,则其访问器没有限制;
2、如果整个属性被设置为protected internal,则访问器的访问修饰仅能设置为internal,protected或者private中的一种;
3、如果整个属性被设置为internal或者protected,那么访问器的访问修饰只能是private。
如下例:
public Class A
{
private string m_text;
private int m_count;
public string Text
{
get {return m_text;}
protected set { m_text = value;}
}
internal int Count
{
private get {return 5;}
private set {m_count = value}
}
}
从程序的实质来看,其实属性就是一种特殊的方法,它等同于下面的代码:
public int GetAge()
{
return m_age;
}
public void SetAge(int age)
{
m_age = age;
}
从这个意义上来理解get/set访问器的访问级别修饰,就更容易理解了。实质上,所谓的访问器的访问级别修饰,不外乎就是对方法进行访问级别修饰罢了。当然,C#中提供的属性要比访问字段的get/set方法更加简便。一般而言,如要定义方法,应该是和一个对象的行为有关,例如系列三定义的User类中的SignIn()和SignOut()方法,它们代表的是对象User的行为:登录和退出。
定义一个类的方法,必须包括五个要素:方法修饰符,方法名,返回类型,参数,以及方法体,例如Add方法:
public int Add(int x, int y)
{
return x + y;
}
public即为我们的方法修饰符,它代表了该方法能被访问的级别。当然,修饰的方法的关键字还包括static,virtual,abstract等,不过这些内容会在以后介绍。方法名自然是Add了,自然属于方法的名字。返回类型为int,代表该方法会返回一个结果,该结果类型为int类型。参数有两个,分别为x和y,它们的类型都是int。调用者可以通过参数传递值到方法体中,并对它们进行操作。方法体则是花括号中的内容。
假设Add方法是定义在类Calculator中,那么该方法的调用为:
Calculator cal = new Calculator();
int result = cal.Add(3,5);
通过对Add的调用,并传入3和5的参数,最后得到结果8,并返回。因此,此时变量result的值就为8。而第一行代码,则是利用new关键字对Calculator类进行实例化,获得一个对象cal。通过对象cal,才可以调用Calculator类的公共方法、属性或字段。
为什么要进行实例化呢?我们定义一个类类型,是为调用者所使用的,否则就失去其意义了。但我们定义的这样一个类类型,仅仅是代表了某种格式而已,例如User类说明它是一个class,它拥有了一些字段、属性和方法。通过这样的定义,我们在使用这些类型的对象时,.Net能够识别它。而如果真正要调用这些类型对象,就必须进行”实例化”,这个操作就会在运行期间,创建一个个对象,并被放在内存空间中供程序调用。就好比”人”就是一个类类型,而某一个具体的人,才是被实例化的、真正存在的对象。要使得一个类类型被实例化,就需要为该类型提供”构造器”。构造器是一种特殊的方法,它没有返回类型,且其方法名和类型名保持一致,如Calculator类的定义以及它的构造器:
public class Calculator
{
public Calculator()
{
}
public int Add(int x, int y)
{
return x + y;
}
}
Calculator()方法就是一个”构造器”,这个构造器并没有参数,在C#中,也被称为默认的构造器,即使不定义该构造器,.Net也会为它默认创建。例如在Calculator类中,我们完全可以删去Calculator()构造器的定义。然而,一旦我们定义了有参数的构造器时,则该默认构造器将不存在,如果我们再需要不带参数创建实例的话,就需要显式创建该构造器了。例如之前的User类。如果姓名和密码是该类一个非常重要的属性,大部分情况下,如果要创建User对象时,都需要这两个属性的值时,我们就可以为User类专门创建一个构造器:
public class User
{
public User(string name, string password)
{
m_name = name;
m_password = password;
}
}
注意在这个构造器中,接收两个参数name和password,并将其值赋给User类的字段m_name,m_password。因此,当我们通过如下的方式创建User类的实例时,我们创建的对象就已经具有Name和Password的值了:
User specUser = new User("bruce zhang", "password");
然而此时如果利用下面的方式创建User的实例,就会出现错误:
User user = new User();
因为此时User类的默认构造器(即无参的构造器)已经不存在,如要支持上面的实例化方式,就需要在User类的定义中添加无参构造器。
是否需要为一个类定义有参的构造器,应根据具体的需要而定。以User类而言,由于该类的Name和Password属性是一个对象必备的,那么建立这样一个构造器是有必要的。因为如果不具备这样的构造器,那么如前构造的specUser就需要用下面三行代码来完成:
User specUser = new User();
specUser.Name = "bruce zhang";
specUser.Password = "password";
注意,在一个类的定义中,我们可以使用this关键字来代表其类对象本身,通过this,可以访问到这个类的所有常量、字段、属性和方法,不管它们是public还是private,或者其他访问级别。虽然这个this指代的是类对象本身,也就是说它代表的就是实例化所产生的对象,但this的含义也仅仅限于对象的内部,从对象封装的思想来看,它是被封装了的,在对象外部是无法看到的。
例如下面的定义:
public class Visitor
{
public void Visit(Element e)
{
Console.WriteLine("I was visited.");
}
}
public class Element
{
public void Accept(Visitor v)
{
v.Visit(this);
}
}
在Element类中,Accept方法则传入一个Visitor类型的参数值,在该方法中,调用参数v的方法Visit,而Visit方法传入的是Element类型的值,由于Accept方法本身就属于Element类,因此,我们可以把其自身传递到Visit方法中,也就是代码中的this。
分析如下的代码段:
Visitor v = new Visitor();
Element e = new Element();
e.Accept(v);
Element的实例e,执行了Accept()方法,该方法传入的参数是Visitor类的实例v。那么执行Accept方法,实质就是在其方法内部执行v.Visit()方法,并通过this关键字将对象e本身传入,所以最后的结果是,打印如下的字符串:
I was visited。
这这里顺便提一下命名的要求。所谓命名规范,在作为团队开发的时候,是非常重要的。以本文为例,如何定义类名、字段名、属性名和方法名,都是有讲究的。通常来说,类名、属性名和方法名都要求所有单词的首字母大写。如果是字段,那么除非是公共字段,一般而言,应将第一个单词的首字母小写。不过这也是变量命名的要求。由于在一个类中,可能会临时用到一些变量,而不是字段,为了区别一般变量和字段,C++的程序员喜欢在变量名前加上“_”符号,许多C#程序员也沿用了这个习惯。不过我更喜欢为这些字段名前加上“m_”。命名一定要统一,尤其是在一个团队中,不过类似于这些临时变量,或者非公有变量,对名字的限制要少一些,毕竟这些变量不会被类的调用者使用。此外,对于常量而言,最好定义为全部大写的名字,如前面的定义的常量PI。
C#专门有一套完整的命名规范,有兴趣的可以自己去查阅一下专门的资料。此外,不同的公司可能还有一些特定的命名规范,在这里就不再赘述了。