章节要点:
1.继承的类型
2.实现继承
3.访问修饰符
4.接口
1.继承
上一章中介绍了如何使用#中的各个类,其重点是如何定义单个类(或单个结构)中的方法、属性、构造函数和其它成员。尽管已说明所有的类最终都派生于System.Object类,但并没有说明如何创建继承类的层次结构。本章将讨论C#和.NET Framework如何处理继承。
2.继承和类型
首先介绍C#在继承方面支持和不支持的功能
1.实现继承和接口继承
在面相对象的编程中,有两种截然不同的继承类型:实现继承和接口继承。
*实现继承:表示一个类型派生于一个基类型,它拥有该基类型的所有成员字段和函数。在实现继承中,派生类型采用基类型的每个函数的实现代码,除非在派生类型的定义中指定重写某个函数的实现代码。在需要给现有的类型添加功能,或许多相关的类型共享一组重要的公共功能时,这种类型的继承非常有用。
*接口继承:表示一个类型只继承了函数的签名,没有继承任何实现代码。在需要指定该类型具有某些可用的特性时,最好使用这种类型的继承。
C#支持实现继承和接口继承。它们都内置与语言和架构中,因此可以根据应用程序的体系结构选择合适的继承。
2.多重继承
一些语言(如C++)支持所谓的“多重继承”,即一个类派生继承自多个类。使用多重继承的优点是有争议的:一方面,毫无疑问,可以使用多重继承编写非常复杂、但很紧凑的代码,如C++ATL库。另一方面,使用多重实现继承的代码常常很难理解和调试。如前所述,简化健壮代码的编写工作是开发C#的重要设计目标。因此,C#不支持多重实现继承。而C#又允许类型派生自多个接口------多重接口继承。这说明,C#类可以派生自另一个类的任意多个接口。更准确的说,因为System.Object是一个公共的基类,所以每个C#类(除了Object类以外)都有一个基类,还可以有任意多个基接口。
3.结构和类
使用结构的一个限制是结构不支持继承,但每个结构都派生自System.ValueType。不能编码实现类型层次的结构,但结构可以实现接口。换言之,结构并不支持实现继承,但支持接口继承。定义结构和类可以总结为:
*结构总是派生自System.ValueType,他们还可以派生自任意多个接口。
*类总是派生自System.Object或用户选择的另一个类,它们还可以派生自任意多个接口。
3.实现继承
如果要声明派生自另一个类的一个类,就需要使用下面的语法:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication3 { public class MyClassName { public string m_MyName; } public class MyClass : MyClassName { public int m_MyAge; public MyClass(string _name, int _age) { base.m_MyName = _name; this.m_MyAge = _age; } public void Printf() { Console.WriteLine("我的名字是:{0} 我今年{1}岁了!", base.m_MyName, this.m_MyAge); } } class Program { static void Main(string[] args) { MyClass mc = new MyClass("Dean", 55); mc.Printf(); Console.ReadKey(); } } }
如果类(或结构)也派生自接口,则用逗号分隔列表中的基类和接口。
如果在类定义中没有指定的基类,C#编译器就假定System.Object是基类。
1.虚方法
把一个基类函数声明为virtual,就可以在任何派生类中重写该函数,也可以把属性声明为virtual。对于虚属性或重写属性,语法与非虚属性相同,但要在定义中添加关键字virtual,其语法如下所示:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication4 { public class MyClass { private int m_Age; public virtual void VirtualPrintf() { Console.WriteLine("MyClass's VirtualMethod"); } public virtual int AGE { get { return m_Age; } set { m_Age = value; } } } public class MyClass2 : MyClass { } class Program { static void Main(string[] args) { } } }
为了简单起见,下面的讨论主要集中于方法,但其规则也适用于属性。
C#中虚函数的概念与OOP的概念相同:可以在派生类中重写虚函数。在调用方法时,会调用该类对象的合适方法。在C#中,函数在默认情况下不是虚拟的,但(除了构造函数以外)可以显示地声明为virtual。这遵循C++的方式,即从性能的角度来看,除非是显示指定,否则函数就不是虚拟的。而在Java中,所有的虚函数都是虚拟的。但C#的语法与C++的语法不用,因为C#要求在派生类的函数重写另一个函数时,要使用override关键字显示声明:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication4 { public class MyClass { private int m_Age; public virtual void VirtualPrintf() { Console.WriteLine("MyClass's VirtualMethod"); } public virtual int AGE { get { return m_Age; } set { m_Age = value; } } } public class MyClass2 : MyClass { } public class MyClass3 : MyClass { public override void VirtualPrintf() { Console.WriteLine("MyClass3's VirtualMethod"); } } class Program { static void Main(string[] args) { MyClass2 mc2 = new MyClass2(); mc2.VirtualPrintf(); MyClass3 mc3 = new MyClass3(); mc3.VirtualPrintf(); Console.ReadKey(); } } }
重写方法的语法避免了C++中很容易发生的潜在运行错误:当派生类的方法签名无意中与基类版本略有差别时,该方法就不能重写基类的方法。在C#中,这会出现一个编译错误,因为编译器会认为函数已标记为override,但没有重写其基类的方法。
成员字段和隐态函数都不能声明为virtual,因为这个概念只对类中的实例函数成员有意义。
2.隐藏方法
如果签名相同的方法在基类和派生类中都进行了声明,但该方法没有声明为virtual和override,派生类方法就会隐藏基类方法。
在大多数情况下,是要重写方法,而不是隐藏方法,因为隐藏方法会造成对于给定类的实例调用错误方法的危险。但是,如下面的例子所示,C#语法可以确保开发人员在编译时收到这个潜在错误的警告,从而使隐藏方法(如果这确实是用户的本意)更加安全。这也是类库开发人员得到的版本方面的好处。
3.调用函数的基类版本
C#有一种特殊的语法用于从派生类中调用方法的基类版本:base.<MethodName>()。例如,假定派生类中的一个方法要返回基类的方法90%的返回值,就可以使用下面的语法:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication5 { class MyClass1 { public virtual decimal ReturnNumber() { return 10.0M; } } class MyClass2 : MyClass1 { public override decimal ReturnNumber() { return base.ReturnNumber() * 0.9M; } } class Program { static void Main(string[] args) { Console.ReadKey(); } } }
4.抽象类和抽象函数
C#允许把类和函数声明为abstract。抽象类不能实例化,而抽象函数不能直接实现,必须在非抽象的派生类中重写。显然,抽象函数本身也是虚拟的(尽管不需要提供virtual关键字,实际上,如果提供了该关键字,就会产生一个语法错误。)如果类包含抽象函数,则该类也是抽象的,也必须声明为抽象的:
5.密封类和密封方法
C#允许把类和方法声明为sealed。对于类,这表示不能继承该类;对于方法,这表示不能重写该方法。
要在方法或属性上使用sealed关键字,必须先从基类上把它声明为要重写的方法或属性。如果基类上不希望有重写的方法或属性,就不要把它声明为virtual。
6.派生类的构造函数
抛出问题:在开始为层次结构中的类(这个类继承了其他也可能有自定义构造函数的类)定义自己的构造函数时,会发生什么情况?
假定没有为任何类定义任何显示的构造函数,这样编译器就会为所有的类提供默认的初始化构造函数,在后台会进行许多操作,但编译器可以很好地解决类的层次结构中的所有问题,每个类中的每个字段都会初始化为对应的默认值。但在添加了一个我们自己的构造函数后,就要通过派生类的层级结构高效地控制构造过程,因此必须确保构造过程顺利进行,不要出现不能按照层级结构进行构造的问题。
为什么派生类会有某些特殊的问题?原因是在创建派生类的实例时,实际上会有多个构造函数起作用。要实例化的类的构造函数本身不能初始化类,还必须调用基类中的构造函数。这就是为什么要通过层级结构进行构造的原因。
注意构造函数的执行顺序:最先调用的总是基类的构造函数。也就是说,派生类的构造函数可以在执行过程中调用它可以访问的任何基类方法、属性和任何其它成员,因为基类已经构造出来了,其字段也初始化了。这意味着,如果派生类不喜欢初始化基类的方式,那么只要它能访问基类的数据,就可以改变数据的初始值。但是好的编程方式几乎总是应尽可能避免这种情况,让基类构造函数来处理字段。
1.在层次结构中添加无参的构造函数
2.在层级结构中添加带参的构造函数
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication6 { abstract class CustomerInfo { private string m_Name; public CustomerInfo(string _name) { m_Name = _name; Console.WriteLine("CustomerInfo 父类的构造函数!"); } } class Nevermore60Customer : CustomerInfo { private int m_HighCostMinutesUsed; private string m_refererName; public Nevermore60Customer(string name, int _minutes) : this(name, "<None>", _minutes) { } public Nevermore60Customer(string _name, string _refererName, int _minutes) : base(_name) { m_refererName = _refererName; m_HighCostMinutesUsed = _minutes; Console.WriteLine("Nevermore60Customer 派生类的构造函数!"); } } class Program { static void Main(string[] args) { Nevermore60Customer nc = new Nevermore60Customer("Dean", 200); Console.ReadKey(); } } }
4.修饰符
1.可见性修饰符
修饰符表:
修饰符名称 应用于 说明
public 所有类型或成员 任何代码均可以访问该项
protected 类型和内嵌类型的所有成员 只有派生的类型能访问该项
internal 所有类型或成员 只能包含它的程序集中访问该项
private 类型和内嵌类型的所有成员 只能在它所属的类型中访问该项
protected internal 类型和内嵌类型的所有成员 只能在包含它的程序集和派生类型的任何代码中访问该项
注意,类型定义可以是内部或共有的,这取决与是否希望在包含类型的程序集外部访问它:
不能把类型定义为protected、private、protected internal,因为这些修饰符对于包含在名称空间中的类型没有意义。因此这些修饰符只能应用于成员。但是,可以用这些修饰符定义嵌套的类型(即,包含在其它类型中的类型),因为在这种情况下,类型也具有成员的状态。
2.其它修饰符
修饰符 应用于 说明
new 函数成员 成员用相同的签名隐式继承的成员
static 所有成员 成员不作用于类的具体实例
virtual 仅函数成员 成员可以由派生类重写
abstract 仅函数成员 虚拟成员定义了成员的签名,但没有提供实现代码
override 仅函数成员 成员重写了继承的虚拟或抽象成员
sealed 类、方法和属性 对于类。不能继承自密封类。对于属性和方法,成员重写已继承的虚拟成员,但任何派生类中的任何成员都不能重写该成员。该修饰符必须与override一起使用
extern 仅静态方法 成员在外部用另一种语言实现
5.接口
如果一个类派生自一个接口,声明这个类就会实现某些函数。并不是所有的面相对象语言都支持接口。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleApplication6 { interface Interface1 { void Dispose(); } }
上面的代码说明,声明接口在语法上与声明抽象类完全相同,但不允许提供接口中任何成员的实现方式。一般情况下,接口只能包含方法、属性、索引器和事件的声明。
不能实例化接口,它只能包含其成员的签名。接口既不能有构造函数,也不能有字段。接口定义也不允许包含运算符重载,尽管这不是因为声明它们在原则上有什么问题,而是因为接口通常是公共协定,包含运算符重载会引起一些与其它.NET语言不兼容的问题。
在接口定义中还不允许声明关于成员的修饰符。接口成员总是公有的,不能声明为虚拟或静态。如果有需要,就应由实现的类来声明,因此最好实现执行的类来声明访问修饰符。
1.定义和实现接口
建立在银行账户的基础上。假定编写代码,最终允许在银行账户之间进行计算机转账业务。许多公司可以实现银行账户,但它们一致认为,表示银行账户的所有类都实现接口IBankAccount。该接口包含一个用于存取款的方法和一个返回余额的属性。这个接口还允许外部代码识别由不同银行账户实现的各种银行账户类。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Wrox.ProCSharp { interface IBankAccount { void PayIn(decimal _amount); bool Withdraw(decimal _amount); decimal Balance { get; } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Wrox.ProCSharp.VenusBank { class SaverAccount : IBankAccount { private decimal m_Balance; //存钱 存入 public void PayIn(decimal _amount) { m_Balance += _amount; } public bool Withdraw(decimal _amount) { if (m_Balance >= _amount) { m_Balance -= _amount; return true; } Console.WriteLine("Withdrawal attempt failed!"); return false; } public decimal Balance { get { return m_Balance; } } public override string ToString() { return String.Format("Venus Bank Saver : m_Balance = {0, 6:C}", m_Balance); } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Wrox.ProCSharp.JupiterBank { class GoldAccount : IBankAccount { private decimal m_Balance; //存钱 存入 public void PayIn(decimal _amount) { m_Balance += _amount; } public bool Withdraw(decimal _amount) { if (m_Balance >= _amount) { m_Balance -= _amount; return true; } Console.WriteLine("Withdrawal attempt failed!"); return false; } public decimal Balance { get { return m_Balance; } } public override string ToString() { return String.Format("Jupiter Bank Saver : m_Balance = {0, 6:C}", m_Balance); } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Wrox.ProCSharp; using Wrox.ProCSharp.JupiterBank; using Wrox.ProCSharp.VenusBank; namespace ConsoleApplication6 { class Program { static void Main(string[] args) { IBankAccount venusAccount = new SaverAccount(); IBankAccount jupiterBank = new GoldAccount(); venusAccount.PayIn(200); venusAccount.Withdraw(100); Console.WriteLine(venusAccount.ToString()); Console.WriteLine(); jupiterBank.PayIn(500); jupiterBank.Withdraw(600); jupiterBank.PayIn(100); Console.WriteLine(jupiterBank.ToString()); Console.WriteLine(); GoldAccount ga = new GoldAccount(); ga.PayIn(500); ga.Withdraw(150); Console.WriteLine(ga.ToString()); Console.WriteLine(); Console.ReadKey(); } } }
在这段代码中,要点是把两个引用变量声明为IBankAccount引用的方式。这表示它们可以指向实现这个接口的任何类的任何实例。但我们只能通过这些引用调用接口的一部分方法------如果要调用由类实现的但不在接口中的方法,就需要把引用强制转换为合适的类型。在这段代码中,我们调用了ToString()(不是IBankAccount实现的),但没有进行任何显式的强制转换,这只是因为ToString()是一个System.Object方法,因此C#编译器知道任何类都支持这个方法(换言之,从任何接口到System.Object的数据类型强制转换是隐式的)。
接口引用完全可以看成类引用------但接口引用的强大之处在于,它可以引用任何实现该接口的类。例如,我们可以构造接口数组,其中数组的每个元素都是不同的类。
2.派生的接口
接口可以彼此继承,其方式与类的继承方式相同。下面通过定义一个新的接口ITransferBankAccount来说明这个概念,该接口的功能与IBankAccount相同,只是又定义了一个方法,把资金直接转到另一个账户上。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Wrox.ProCSharp { interface ITransferBankAccount : IBankAccount { bool TransferTo(IBankAccount _destination, decimal _amount); } }
因为ITransferBankAccount派生自IBankAccount,所以它拥有IBankAccount的所有成员和它自己的成员。这表示实现(派生自)ITransferBankAccount的任何类都必须实现IBankAccount的所有方法和在ITransferBankAccount中定义的新方法TransferTo()。没有实现所有这些方法就会产生编译错误。
注意,TransferTo()方法对于目标账户使用了IBankAccount接口引用。这说明了接口的用途:在实现并调用这个方法时,不必知道转账的对象类型,只需要知道该对象实现IBankAccount即可。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Wrox.ProCSharp.CurrentBank { class CurrentAccount : ITransferBankAccount { private decimal m_Balance; public void PayIn(decimal _amount) { m_Balance += _amount; } public bool Withdraw(decimal _amount) { if (m_Balance >= _amount) { m_Balance -= _amount; return true; } Console.WriteLine("Withdrawal attempt failed!"); return false; } public decimal Balance { get { return m_Balance; } } public bool TransferTo(IBankAccount _destination, decimal _amount) { bool result; result = Withdraw(_amount); if (result) { _destination.PayIn(_amount); } return result; } public override string ToString() { return String.Format("Jupiter Bank Saver : m_Balance = {0, 6:C}", m_Balance); } } }
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Wrox.ProCSharp; using Wrox.ProCSharp.JupiterBank; using Wrox.ProCSharp.VenusBank; using Wrox.ProCSharp.CurrentBank; namespace ConsoleApplication6 { class Program { static void Main(string[] args) { IBankAccount venusAccount = new SaverAccount(); ITransferBankAccount jupiterAccount = new CurrentAccount(); venusAccount.PayIn(200); jupiterAccount.PayIn(500); jupiterAccount.TransferTo(venusAccount, 100); Console.WriteLine(venusAccount.ToString()); Console.WriteLine(jupiterAccount.ToString()); Console.ReadKey(); } } }
6.小结
在此,介绍了如何在C#中进行继承。C#支持多接口继承和单一实现继承,还提供了许多有用的语法结构,以使代码更健壮,如override关键字,它表示函数应在何时重写基类函数,new关键字表示函数在何时隐藏基类函数,构造函数初始化器的硬性规则可以确保构造函数以健壮的方式进行交互操作。