如果要声明一个类派生于另一个类,可以使用下面的语法:
class MyDerivedClass : MyBaseClass
{
// functions and data members here
}
注意:
这个语法非常类似于C++和Java中的语法,但是,C++程序员习惯于使用公共和私有继承的概念,要注意C#不支持私有继承,因此基类名上没有public或private限定符。支持私有继承会大大增加语言的复杂性,实际上私有继承在C++中也很少使用。
如果类(或结构)也派生于接口,则用逗号分隔开基类和接口:
public class MyDerivedClass : MyBaseClass, IInterface1, IInterface2
{
//etc.
}
对于结构,语法如下:
public struct MyDerivedStruct : IInterface1, IInterface2
{
//etc.
}
如果在类定义中没有指定基类,C#编译器就假定System.Object是基类。因此下面的两段代码生成相同的结果:
class MyClass : Object //derives from System.Object
{
//etc.
}
和
class MyClass //derives from System.Object
{
//etc.
}
第二种形式比较常用,因为它较简单。
C#支持object关键字,它用作System.Object类的假名,所以也可以编写下面的代码:
class MyClass : object //derives from System.Object
{
//etc.
}
如果要引用Object类,可以使用object关键字,智能编辑器(如VS.NET)会识别它,因此便于编辑代码。
把一个基类函数声明为virtual,该函数就可以在派生类中重写了:
class MyBaseClass
{
public virtual string VirtualMethod()
{
return "This method is virtual and defined in MyBaseClass";
}
}
也可以把属性声明为virtual。对于虚属性或重写属性,语法与非虚属性是相同的,但要在定义中加上关键字virtual,其语法如下所示:
public virtual string ForeName
{
get { return foreName; }
set { foreName = value; }
}
private string foreName;
为了简单起见,下面的讨论将主要集中于方法,但其规则也适用于属性。
C#中虚函数的概念与标准OOP概念相同:可以在派生类中重写虚函数。在调用方法时,会调用对象类型的合适方法。在C#中,函数在默认情况下不是虚拟的,但(除了构造函数以外)可以显式地声明为virtual。这遵循C++的方式,即从性能的角度来看,除非显式指定,否则函数就不是虚拟的。而在Java中,所有的函数都是虚拟的。但C#的语法与C++的语法不同,因为C#要求在派生类的函数重写另一个函数时,要使用override关键字显式声明:
class MyDerivedClass : MyBaseClass
{
public override string VirtualMethod()
{
return "This method is an override defined in MyDerivedClass";
}
}
方法重写的语法避免了C++中很容易发生的潜在运行错误:当派生类的方法签名无意中与基类版本略有差别时,派生类方法就不能重写基类方法了。在C#中,这会出现一个编译错误,因为编译器会认为函数已标记为override,但没有重写它的基类方法。
成员字段和静态函数都不能声明为virtual,因为这个概念只对类中的实例函数成员有意义。
如果签名相同的方法在基类和派生类中都进行了声明,但该方法没有声明为virtual 和 override,派生类方法就会隐藏基类方法。
在大多数情况下,是要重写方法,而不是隐藏方法,因为隐藏方法会存在为给定类的实例调用错误方法的危险。但是,如下面的例子所示,C#语法可以确保开发人员在编译时收到这个潜在错误的警告,使隐藏方法更加安全。这也是类库开发人员得到的版本方面的好处。
假定有人编写了类HisBaseClass:
class HisBaseClass
{
// various members
}
在将来的某一刻,要编写一个派生类,给HisBaseClass添加某个功能,特别是要添加一个目前基类中没有的方法MyGroovyMethod():
class MyDerivedClass: HisBaseClass
{
public int MyGroovyMethod()
{
// some groovy implementation
return 0;
}
}
一年后,基类的编写者决定扩展基类的功能。为了保持一致,他也添加了一个名为MyGroovyMethod()的方法,该方法的名称和签名与前面添加的方法相同,但并不完成相同的工作。在使用基类的新方法编译代码时,程序在应该调用哪个方法上就会有潜在的冲突。这在C#中完全合法,但因为我们的MyGroovyMethod()与基类的MyGroovyMethod()不相关,运行这段代码的结果就可能不是我们希望的结果。C#已经为此设计了一种方式,可以很好地处理这种情况。
首先,系统会发出警告。在C#中,应使用new关键字声明我们要隐藏一个方法,如下所示:
class MyDerivedClass : HisBaseClass
{
public new int MyGroovyMethod()
{
// some groovy implementation
return 0;
}
}
但是,我们的MyGroovyMethod()没有声明为new,所以编译器会认为它隐藏了基类的方法,但没有显式声明,因此发出一个警告(这也适用于把MyGroovyMethod()声明为 virtual)。如果愿意,可以给我们的方法重命名。这么做,是最好的情形,因为这会避免许多冲突。但是,如果觉得重命名方法是不可能的(例如,已经为其他公司把软件发布为一个库,所以无法修改方法的名称),则所有的已有客户机代码仍能正确运行,选择我们的MyGroovyMethod()。这是因为访问这个方法的已有代码必须通过对MyDerivedClass(或进一步派生的类)的引用进行选择。
已有的代码不能通过对HisBaseClass的引用访问这个方法,因为在对HisBaseClass的早期版本进行编译时,会产生一个编译错误。这个问题只会发生在将来编写的客户机代码上。C#会发出一个警告,告诉用户在将来的代码中可能会出问题——用户应注意这个警告,不要试图在将来的代码中通过对HisBaseClass的引用调用MyGroovyMethod()方法,但所有已有的代码仍会正常工作。这是比较微妙的,但很好地说明了C#如何处理类的不同版本。
C#有一种特殊的语法用于从派生类中调用方法的基类版本:base.<MethodName>()。例如,假定派生类中的一个方法要返回基类的方法返回的值的90%,就可以使用下面的语法:
class CustomerAccount
{
public virtual decimal CalculatePrice()
{
// implementation
return 0.0M;
}
}
class GoldAccount : CustomerAccount
{
public override decimal CalculatePrice()
{
return base.CalculatePrice() * 0.9M;
}
}
这个语法类似于Java,但Java使用关键字super而不是base。C++没有类似的关键字,但需要显式指定类名(CustomerAccount::CalculatePrice())。C++中对应于base的内容都比较模糊,因此C++允许多重继承。
注意,可以使用base.<MethodName>()语法调用基类中的任何方法,不必在同一个方法的重载中调用它。
C#允许把类和函数声明为abstract,抽象类不能实例化,而抽象函数没有执行代码,必须在非抽象的派生类中重写。显然,抽象函数也是虚拟的(但也不需要提供virtual关键字,实际上,如果提供了该关键字,就会产生一个语法错误)。如果类包含抽象函数,该类将也是抽象的,也必须声明为抽象的:
abstract class Building
{
public abstract decimal CalculateHeatingCost(); // abstract method
}
C++开发人员要注意C#中的一些语法区别。C#不支持采用=0语法来声明抽象函数。在C#中,这个语法有误导作用,因为可以在类声明的成员字段上使用=<value>,提供初始值:
abstract class Building
{
private bool damaged = false; // field
public abstract decimal CalculateHeatingCost(); // abstract method
}
注意:
C++开发人员还要注意术语上的细微差别:在C++中,抽象函数常常描述为纯虚函数,而在C#中,仅使用抽象这个术语。
C#允许把类和方法声明为sealed。对于类来说,这表示不能继承该类;对于方法来说,这表示不能重写该方法。
sealed class FinalClass
{
// etc
}
class DerivedClass : FinalClass // wrong. Will give compilation error
{
// etc
}
注意:
Java开发人员可以把C#中的sealed当作Java中的final。
在把类或方法标记为sealed时,最可能的情形是:如果要对库、类或自己编写的其他类进行操作,则重写某些功能会导致错误。也可以因商业原因把类或方法标记为sealed,以防第三方以违反注册协议的方式扩展该类。但一般情况下,在把类或方法标记为sealed时要小心,因为这么做会严重限制它的使用。即使不希望它能继承一个类或重写类的某个成员,仍有可能在将来的某个时刻,有人会遇到我们没有预料到的情形。.NET基类库大量使用了密封类,使希望从这些类中派生出自己的类的第三方开发人员无法访问这些类。例如string就是一个密封类。
把方法声明为sealed也可以实现类似的目的,但很少这么做。
class MyClass
{
public sealed override void FinalMethod()
{
// etc.
}
}
class DerivedClass : MyClass
{
public override void FinalMethod() // wrong. Will give compilation error
{
}
}
在方法上使用sealed关键字是没有意义的,除非该方法本身是某个基类上另一个方法的重写形式。如果定义一个新方法,但不想让别人重写它,首先就不要把它声明为virtual。但如果要重写某个基类方法,sealed关键字就提供了一种方式,可以确保为方法提供的重写代码是最终的代码,其他人不能再重写它。
第3章介绍了单个类的构造函数是如何工作的。这样,就产生了一个有趣的问题,在开始为层次结构中的类(这个类继承了其他类,也可能有定制的构造函数)定义自己的构造函数时,会发生什么情况?
假定没有为类定义任何显式的构造函数,这样编译器就会为所有的类提供默认的构造函数,在后台会进行许多操作,编译器可以很好地解决层次结构中的所有问题,每个类中的每个字段都会初始化为默认值。但在添加了一个我们自己的构造函数后,就要通过派生类的层次结构高效地控制构造过程,因此必须确保构造过程顺利进行,不要出现不能按照层次结构进行构造的问题。
为什么派生类会有某些特殊的问题?原因是在创建派生类的实例时,实际上会有多个构造函数起作用。要实例化的类的构造函数本身不能初始化类,还必须调用基类中的构造函数。这就是为什么要通过层次结构进行构造的原因。
为了说明为什么必须调用基类的构造函数,下面是手机公司MortimerPhones开发的一个例子。这个例子包含一个抽象类GenericCustomer,它表示顾客。还有一个(非抽象)类Nevermore60Customer,它表示采用特定付费方式(称为Nevermore60付费方式)的顾客。所有的顾客都有一个名字,由一个私有字段表示。在Nevermore60付费方式中,顾客前几分钟的电话费比较高,需要一个字段highCostMinutesUsed,它详细说明了每个顾客该如何支付这些较高的电话费。抽象类GenericCustomer的定义如下所示:
abstract class GenericCustomer
{
private string name;
// lots of other methods etc.
}
class Nevermore60Customer : GenericCustomer
{
private uint highCostMinutesUsed;
// other methods etc.
}
不要担心在这些类中执行的其他方法,因为这里仅考虑构造过程。如果下载了本章的示例代码,就会发现类的定义仅包含构造函数。
下面看看使用new运算符实例化Nevermore60Customer时,会发生什么情况:
GenericCustomer customer = new Nevermore60Customer();
显然,成员字段name和highCostMinutesUsed都必须在实例化customer时进行初始化。如果没有提供自己的构造函数,而是仅依赖默认的构造函数,name就会初始化为null引用,highCostMinutesUsed初始化为0。下面详细讨论其过程。
highCostMinutesUsed字段没有问题:编译器提供的默认Nevermore60Customer构造函数会把它初始化为0。
那么name呢?看看类定义,显然,Nevermore60Customer构造函数不能初始化这个值。字段name声明为private,这意味着派生的类不能访问它。默认的Nevermore60Customer构造函数甚至不知道存在这个字段。唯一知道这个字段的是GenericCustomer的其他成员,即如果对name进行初始化,就必须在GenericCustomer的某个构造函数中进行。无论类层次结构有多大,这种情况都会一直延续到最终的基类System.Object上。
理解了上面的问题后,就可以明白实例化派生类时会发生什么样的情况了。假定默认的构造函数在整个层次结构中使用:编译器首先找到它试图实例化的类的构造函数,在本例中是Nevermore60Customer,这个默认Nevermore60Customer构造函数首先要做的是为其直接基类GenericCustomer运行默认构造函数,然后GenericCustomer构造函数为其直接基类System.Object运行默认构造函数,System. Object没有任何基类,所以它的构造函数就执行,并把控制返回给GenericCustomer构造函数。现在执行GenericCustomer构造函数,把name初始化为null,再把控制权返回给Nevermore60Customer构造函数,接着执行这个构造函数,把highCostMinutesUsed初始化为0,并退出。此时,Nevermore60Customer实例就已经成功地构造和初始化了。
构造函数的调用顺序是先调用System.Object,再按照层次结构由上向下进行,直到到达编译器要实例化的类为止。还要注意在这个过程中,每个构造函数都初始化它自己的类中的字段。这是它的一般工作方式,在开始添加自己的构造函数时,也应尽可能遵循这个规则。
注意构造函数的执行顺序。基类的构造函数总是最先调用。也就是说,派生类的构造函数可以在执行过程中调用它可以访问的基类方法、属性和其他成员,因为基类已经构造出来的,其字段也初始化了。如果派生类不喜欢初始化基类的方式,但要访问数据,就可以改变数据的初始值,但是,好的编程方式应尽可能避免这种情况,让基类构造函数来处理其字段。
理解了构造过程后,就可以开始添加自己的构造函数了。
首先讨论最简单的情况,在层次结构中用一个无参数的构造函数来替换默认的构造函数后,看看会发生什么情况。假定要把每个人的名字初始化为<no name>,而不是null引用,修改GenericCustomer中的代码,如下所示:
public abstract class GenericCustomer
{
private string name;
public GenericCustomer()
: base() // we could omit this line without affecting the compiled code
{
name = "<no name>";
}
添加这段代码后,代码运行正常。Nevermore60Customer仍有自己的默认构造函数,所以上面描述的事件顺序仍不变,但编译器会使用定制的GenericCustomer构造函数,而不是生成默认的构造函数,所以name字段按照需要总是初始化为<no name>。
注意,在定制的构造函数中,在执行GenericCustomer构造函数前,添加了一个对基类构造函数的调用,使用的语法与前面解释如何让构造函数的不同重载版本互相调用时使用的语法相同。唯一的区别是,这次使用的关键字是base,而不是this,表示这是基类的构造函数,而不是要调用的类的构造函数。在base关键字后面的圆括号中没有参数,这是非常重要的,因为没有给基类构造函数传送参数,所以编译器会调用无参数的构造函数。其结果是编译器会插入调用System.Object构造函数的代码,这正好与默认情况相同。
实际上,可以把这行代码删除,只加上为本章中大多数构造函数编写的代码:
public GenericCustomer()
{
name = "<no name>";
}
如果编译器没有在起始花括号的前面找到对另一个构造函数的任何引用,它就会假定我们要调用基类构造函数——这符合默认构造函数的工作方式。
base 和 this关键字是调用另一个构造函数时允许使用的唯一关键字,其他关键字都会产生编译错误。还要注意只能指定一个其他的构造函数。
到目前为止,这段代码运行正常。但是,要通过构造函数的层次结构把级数弄乱的最好方法是把构造函数声明为私有:
private GenericCustomer()
{
name = "<no name>";
}
如果试图这样做,就会产生一个有趣的编译错误,如果不理解构造是如何按照层次结构由上而下的顺序工作的,这个错误会让人摸不着头脑。
'Wrox.ProCSharp.GenericCustomer()' is inaccessible due to its protection level
有趣的是,该错误没有发生在GenericCustomer类中,而是发生在Nevermore60Customer派生类中。编译器试图为Nevermore60Customer生成默认的构造函数,但又做不到,因为默认的构造函数应调用无参数的GenericCustomer构造函数。把该构造函数声明为private,它就不可能访问派生类了。如果为带有参数的GenericCustomer提供一个构造函数,但没有提供无参数的构造函数,也会发生类似的错误。在本例中,编译器不能为GenericCustomer生成默认构造函数,所以当编译器试图为派生类生成默认构造函数时,会再次发现它不能做到这一点,因为没有无参数的基类构造函数可调用。这个问题的解决方法是为派生类添加自己的构造函数—— 实际上不需要在这些构造函数中做任何工作,这样,编译器就不会为这些派生类生成默认构造函数了。
前面介绍了所有的理论知识,下面用一个例子来说明如何给类的层次结构添加构造函数。下一节为MortimerPhones样例添加带参数的构造函数。
首先是带一个参数的GenericCustomer构造函数,它仅在顾客提供其姓名时才实例化顾客:
abstract class GenericCustomer
{
private string name;
public GenericCustomer(string name)
{
this.name = name;
}
到目前为止,代码运行一切正常,但刚才说过,在编译器试图为派生类创建默认构造函数时,会产生一个编译错误,因为编译器为Nevermore60Customer生成的默认构造函数会试图调用无参数的GenericCustomer构造函数,但GenericCustomer没有这样的构造函数。因此,需要为派生类提供一个构造函数,来避免这个错误:
class Nevermore60Customer : GenericCustomer
{
private uint highCostMinutesUsed;
public Nevermore60Customer(string name)
: base(name)
{
}
现在,Nevermore60Customer对象的实例化只能在提供了包含顾客姓名的字符串后进行,这正是我们需要的。有趣的是Nevermore60Customer构造函数对这个字符串所做的处理。它本身不能初始化name字段,因为它不能访问基类中的私有字段,但可以把顾客姓名传送给基类,以便GenericCustomer构造函数处理。具体方法是,把先执行的基类构造函数指定为把顾客姓名当做参数的构造函数。除此之外,它不需要执行任何操作。
下面讨论如果要处理不同的重载构造函数和一个类的层次结构,会发生什么情况。假定Nevermore60Customers通过朋友联系到MortimerPhones,即MortimerPhones公司中有一个人是朋友,因此可以获得折扣。这表示在构造一个Nevermore60Customer时,还需要传递联系人的姓名。在现实生活中,构造函数必须利用该姓名去完成更复杂的工作,例如处理折扣等,但这里只是把联系人的姓名存储到另一个字段中。
此时,Nevermore60Customer定义如下所示:
class Nevermore60Customer : GenericCustomer
{
public Nevermore60Customer(string name, string referrerName)
: base(name)
{
this.referrerName = referrerName;
}
private string referrerName;
private uint highCostMinutesUsed;
该构造函数将姓名作为参数,把它传递给GenericCustomer构造函数进行处理。referrerName是一个变量,我们需要声明它,这样构造函数才能在其主体中处理这个参数。
但是,并不是所有的Nevermore60Customers都有联系人,所以还需要有一个不需此参数的构造函数(或为它提供默认值的构造函数)。实际上,我们指定如果没有联系人,referrerName字段就设置为<None>。下面是这个带一个参数的构造函数:
public Nevermore60Customer(string name)
: this(name, "<None>")
{
}
这样就正确建立了所有的构造函数。执行下面的代码时,检查事件链是很有益的:
GenericCustomer customer = new Nevermore60Customer("Arabel Jones");
编译器认为它需要带一个字符串参数的构造函数,所以它确认的构造函数就是刚才定义的那个构造函数,如下所示。
public Nevermore60Customer(string Name)
: this(Name, "<None>")
在实例化customer时,就会调用这个构造函数。之后立即把控制权传送给对应的Nevermore60Customer构造函数,该构造函数带2个参数,分别是Arabel Jones和<None>。在这个构造函数中,把控制权依次传送给GenericCustomer构造函数,该构造函数带有1个参数,即字符串Arabel Jones。然后这个构造函数把控制权传送给System.Object默认构造函数。现在执行这些构造函数,首先执行System.Object构造函数,接着执行GenericCustomer构造函数,初始化name字段。然后带有两个参数的Nevermore60Customer构造函数得到控制权,把联系人的姓名初始化为<None>。最后,执行Nevermore60Customer构造函数,该构造函数带有1个参数—— 这个构造函数什么也不做。
这个过程非常简洁,设计也很合理。每个构造函数都处理变量的初始化。在这个过程中,正确地实例化了类,以备使用。如果在为类编写自己的构造函数时遵循这个规则,即便是最复杂的类,也可以顺利地初始化,不会出现任何问题。