3.2 继承
类可以通过继承另一个类来对自身进行扩展或定制。继承类可以重用被继承类所有功能而无须重新构建。类只能继承自唯一的类,但是可以被多个类继承,从而形成了类的树形结构。在本例中,我们定义一个名为Asset的类:
接下来我们定义Stock和House这两个类,它们都继承了Asset类,具有Asset类的所有特征,而各自又有自身新增的成员定义:
下面是这两个类的使用方法:
派生类(derived class)Stock和House都从基类Asset中继承了Name字段。
派生类也称为子类(subclass)。
基类也称为超类(superclass)。
3.2.1 多态
引用是多态的,这意味着x类型的变量可以指向x子类的对象。例如,考虑如下的方法:
上述方法可以用来显示Stock和House的实例,因为这两个类都继承自Asset:
多态之所以能够实现,是因为子类(Stock和House)具有基类(Asset)的全部特征,反过来则不正确。如果Display转而接受House对象,则不能够把Asset对象传递给它。
3.2.2 类型转换和引用转换
对象引用可以:
· 隐式向上转换为基类的引用
· 显式向下转换为子类的引用
各个兼容的类型的引用间向上或向下类型转换仅执行引用转换,即(逻辑上)生成一个新的引用指向同一个对象。向上转换总是能够成功,而向下转换只有在对象类型符合要求时才能成功。
3.2.2.1 向上类型转换
向上类型转换即从一个子类引用创建一个基类的引用,例如:
向上转换之后,变量a仍然是msft指向的Stock对象。被引用的对象本身不会被替换或者改变:
虽然a与msft均引用同一对象,但a在该对象上的视图更加严格:
上例中的最后一行会产生一个编译时错误,这是因为虽然变量a实际引用了Stock类型的对象,但它的(声明)类型仍为Asset。因而若要访问SharesOwned字段,必须将Asset向下转换为Stock。
3.2.2.2 向下类型转换
向下转换则是从基类引用创建一个子类引用。例如:
向上转换仅仅影响引用,而不会影响被引用的对象。向下转换则必须是显式转换,因为它有可能导致运行时错误:
如果向下转换失败,则会抛出InvalidCastException,这是一种运行时类型检查(我们还会在3.3.2节详细介绍这个概念)。
3.2.2.3 as运算符
as运算符在向下类型转换出错时返回null(而不是抛出异常):
这个操作相当有用,接下来只需判断结果是否为null即可:
如果不用判断结果是否为null,那么更推荐使用类型转换。因为如果发生错误,那么类型转换会抛出描述更清晰的异常。我们可以通过比较下面两行代码来说明:
如果a不是Stock类型,则第一行代码会抛出InvalidCastException,这很清晰地描述了错误。而第二行代码会抛出NullReferenceException,这就比较模糊。因为不容易区分a不是Stock类型和a是null这两种不同的情况。
从另一个角度看,使用类型转换运算符就是告诉编译器:“我确定这个值的类型,如果判断错误,那么说明代码有缺陷,请抛出一个异常!”而如果使用as运算符,则表示不确定其类型,需要根据运行时输出结果来确定执行的分支。
as运算符不能执行自定义转换(请参见4.17节),也不能用于数值转换:
as和类型转换运算符也可以用来实现向上类型转换,但是不常用,因为隐式转换就已经足够了。
3.2.2.4 is运算符
is运算符用于检测变量是否满足特定的模式。C#支持若干模式,其中最重要的模式是类型模式。在这种模式下,is运算符后跟类型的名称。
在类型模式上下文中,is运算符检查引用的转换是否能够成功,即对象是否从某个特定的类派生(或者实现某个接口)。该运算符常在向下类型转换前使用:
如果拆箱转换(unboxing conversion)能成功执行,则is运算符也会返回true(参见3.3节),但它不能用于自定义类型转换和数值转换。
除类型模式之外,is运算符还支持C#近期引入的多种其他模式,完整的介绍请参见4.13节。
3.2.2.5 引入模式变量
我们可以在使用is运算符时引入一个变量:
上述代码等价于:
引入的变量可以“立即”使用,因此以下代码是合法的:
同时,引入的变量即使在is表达式之外也仍然在作用域内,例如:
3.2.3 虚函数成员
子类可以重写(override)标识为virtual的函数以提供特定的实现。方法、属性、索引器和事件都可以声明为virtual:
Liability => 0是{ get { return 0; } }的简写,更多关于该语法的介绍,请参见3.1.8.2节。
子类可以使用override修饰符重写虚方法:
默认的情况下,Asset类型的Liability属性为0,Stock类不用限定这一行为,而House类则令Liability属性返回Mortgage的值:
虚方法和重写的方法的签名、返回值以及可访问性必须完全一致。重写的方法可以通过base关键字调用其基类的实现(我们将在3.2.7节介绍)。
从构造器调用虚方法有潜在的危险性,因为编写子类的人在重写方法的时候未必知道现在正在操作一个未完全实例化的对象。换言之,重写的方法很可能最终会访问到一些方法或属性,而这些方法或属性依赖的字段还未被构造器初始化。
协变返回类型
从C# 9开始,我们可以在重写方法(或属性的get访问器)时返回派生类型(子类型),例如:
上述重写是合法的,因为它并没有破坏Clone方法的契约(即必须返回Asset类型的对象)。它返回了更具体的House,但仍然是一个Asset。
在C# 9之前,重写的方法必须具有一致的返回类型:
上述方法和先前示例中的行为是一样的。因为重写的Clone方法中初始化的类型仍然是House而非Asset。但是,如果想要将返回对象当作House处理就需要执行一次向下类型转换了:
3.2.4 抽象类和抽象成员
声明为抽象(abstract)的类不能实例化,只有抽象类的具体实现子类才能实例化。
抽象类中可以定义抽象成员,抽象成员和虚成员相似,只不过抽象成员不提供默认的实现。除非子类也声明为抽象类,否则其实现必须由子类提供:
3.2.5 隐藏继承成员
有时,基类和子类可能会定义(名称)相同的成员,例如:
类B中的Counter字段隐藏了类A中的Counter字段。通常,这种情况是在定义了子类成员之后又意外地将其添加到基类中而造成的。因此,编译器会产生一个警告,并采用下面的方法避免这种二义性:
· A的引用(在编译时)绑定到A.Counter。
· B的引用(在编译时)绑定到B.Counter。
有时需要故意隐藏一个成员。此时可以在子类的成员上使用new修饰符。new修饰符仅用于阻止编译器发出警告,写法如下:
new修饰符可以明确将你的意图告知编译器和其他开发者:重复的成员是有意义的。
C#在不同上下文中的new关键字拥有完全不同的含义。特别注意,new运算符和new修饰符是不同的。
new和重写
请观察以下的类层次:
以下代码展示了Overrider和Hider的不同行为:
3.2.6 密封函数和类
重写的函数成员可以使用sealed关键字进行密封,以防止被其他的子类再次重写。在前面的虚函数成员示例中,我们可以密封House类的Liability实现,来防止继承了House的子类重写Liability这个属性:
在类上使用sealed修饰符也可以防止类的继承。密封类比密封函数成员更常见。
虽然密封函数成员可以防止重写,但是它却无法阻止成员被隐藏。
3.2.7 base关键字
base关键字和this关键字很相似,它有两个重要目的:
· 从子类访问重写的基类函数成员。
· 调用基类的构造器(见3.2.8节)。
本例中,House类用关键字base访问Asset类对Liability的实现:
我们使用base关键字用非虚的方式访问Asset的Liability属性。这意味着不管实例的运行时类型如何,都将访问Asset类的相应属性。
如果Liability是隐藏属性而非重写的属性,该方法也同样有效。也可以在调用相应函数前,将其转换为基类来访问隐藏的成员。
3.2.8 构造器和继承
子类必须声明自己的构造器。派生类可以访问基类的构造器,但是并非自动继承。例如,如果我们定义了如下的Baseclass和Subclass:
则下面的语句是非法的:
Subclass必须重新定义它希望对外公开的任何构造器。不过,它可以使用base关键字调用基类的任何一个构造器:
base关键字和this关键字很像,但base关键字调用的是基类的构造器。
基类的构造器总是先执行,这保证了基类的初始化先于子类的特定初始化。
3.2.8.1 隐式调用基类的无参数构造器
如果子类的构造器省略base关键字,那么将隐式调用基类的无参数构造器:
如果基类没有可访问的无参数的构造器,子类的构造器中就必须使用base关键字。
3.2.8.2 构造器和字段初始化的顺序
当对象实例化时,初始化按照以下的顺序进行:
1.从子类到基类:
a)初始化字段。
b)计算被调用的基类构造器中的参数。
2.从基类到子类:
a)构造器方法体的执行。
例如:
3.2.9 重载和解析
继承对方法的重载有着特殊的影响。请考虑以下两个重载:
当重载被调用时,优先匹配最明确的类型:
具体调用哪个重载是在编译器静态时决定的而非运行时决定的。下面的代码调用Foo(Asset),尽管a在运行时是House类型的:
如果把Asset类转换为dynamic(参见第4章),则会在运行时决定调用哪个重载。这样就会基于对象的实际类型进行选择: