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