继承让一个类在另一个类的基础上构建。派生类得到基类的成员,添加自己的成员,并且可以替换基类允许替换的行为。再结合 virtual 方法,你就得到了多态:针对基类编写的代码,无需知道手里是哪个派生类,就能执行正确的派生类行为。
派生一个类
把基类写在冒号后面。派生类拥有基类的一切,再加上它自己声明的内容:
输出:
TX-19: 20 km, 2 fares
True
派生类能得到什么、得不到什么:
- 会被继承:字段、属性、方法、事件和嵌套类型。所有这些都实实在在地存在于对象中。
- 可以访问:只有基类允许的部分:
public、protected和internal成员。Vehicle的private成员存在于每个Taxi中,但Taxi的代码无法使用它的名字。这就是为什么Km带有private set:Taxi可以读取它,但只能通过Drive修改它。 - 不会被继承:构造函数。
Taxi必须声明自己的构造函数,并用: base(plate)链接到Vehicle的某个构造函数。构造函数页面介绍了两者的执行顺序。
每个类最终都派生自 object,所以每个对象都有 ToString()、Equals() 和 GetHashCode()。
virtual 与 override
基类把一个方法标记为 virtual,表示“派生类可以提供自己的版本”。派生类用 override 替换它。在重写的方法内部,base.Method() 会调用基类的版本。
输出:
[email] to lea@example.com: Your order 1042 has shipped today
(unsubscribe link appended)
[sms] to +351 912 000 111: Your order 1042 has...
[generic] to ops-team: Your order 1042 has shipped today
循环变量的类型是 Notification,但每个对象都按自己的方式渲染。这就是多态:n.Render(...) 这个调用在运行时根据对象的实际类型来决定。还要注意,Email 中的 base.Render 用到了 Channel,而它本身也是虚属性,所以基类方法打印的是 email,而不是 generic。基类内部的虚调用同样会走到重写的版本。
正如 Channel 所示,属性也可以是虚的。字段不行。
编译器要求你两个关键字都写。在没有标记 virtual 的方法上写 override 会报 CS0506 错误(“cannot override inherited member ... because it is not marked virtual, abstract, or override”)。当基类方法是虚方法时漏写 override 只会产生一个警告,但含义会完全改变,下一节会说明这一点。
new 与 override:隐藏而不是重写
如果派生类声明了一个与基类方法签名相同的方法,却没有写 override,它就会隐藏基类方法。编译器会给出警告(基类方法是虚方法时为 CS0114,否则为 CS0108),并建议使用 new 关键字;加上 new 可以消除警告,但隐藏行为保持不变:
输出:
Sales report
Report
Draft report
b 和 c 是同一种对象,打印出的标题却不同。使用 new 时,调用哪个方法取决于变量的类型,在编译时就已决定。把报表当作 Report 处理的代码(一个列表、一个方法参数、一个框架回调)永远看不到 DraftReport.Title。这几乎从来不是你想要的。要实现多态就用 override;new 主要用于这样的情况:一个你无法控制的基类新增了一个成员,而它的名字和你已有的成员冲突了。
sealed
类上的 sealed 禁止从它派生:
sealed class Invoice { }
class CorrectedInvoice : Invoice { }
// error CS0509: 'CorrectedInvoice': cannot derive from sealed type 'Invoice'
string 是 sealed 的,许多框架类型也是如此。用在重写的方法上时,sealed 会让重写链在这一层终止:
class Shape { public virtual string Name() => "shape"; }
class Square : Shape { public sealed override string Name() => "square"; }
class Tile : Square { public override string Name() => "tile"; }
// error CS0239: 'Tile.Name()': cannot override inherited member 'Square.Name()' because it is sealed
为继承而设计一个类是需要下功夫的:要决定哪些成员是虚的、派生类可以依赖什么、各步骤按什么顺序发生。没有按这种方式设计的类,密封起来更安全。而且密封以后可以随时解除,不会影响任何人;可一旦别人已经从你的类派生,取消密封就收不回来了。调用 sealed 类的成员还可能稍快一些,因为运行时知道不存在重写版本。
一个基类,多个接口
C# 的类只能有一个基类。class Admin : User, Employee 会报 CS1721 错误(“cannot have multiple base classes”)。不过,一个类可以在基类之外实现任意数量的接口:
class Admin : User, IAuditable, IComparable<Admin>
{
// base class first, then interfaces, in any order
}
“是一种……并且共享其实现”的关系用基类表达,“能做……”的关系用接口表达。如果基类存在的唯一目的是强制派生类实现某些方法,那么抽象类才是合适的工具。
在继承层次中向上和向下转型
派生对象总是可以用在需要其基类型的地方。这种向上转型是隐式的,不会失败。反过来的向下转型需要显式转换,如果对象不是那个类型,就会在运行时失败:
输出:
Rex fetches the ball
InvalidCastException: Tom is not a Dog
True
Rex fetches the ball
不加转换直接写 Dog d = pet; 会导致编译错误(CS0266:“An explicit conversion exists (are you missing a cast?)”),因为编译器只知道 pet 是某种 Animal。当对象可能是其他类型时,优先使用带变量的 is;只有当其他类型一律意味着 bug 时,才使用普通的强制转换。频繁的向下转型是设计上的坏味道:通常说明这个行为应该放在基类的虚方法里。模式匹配页面完整介绍了 is 的各种写法。
继承与集合
多态按元素逐个生效,但派生类型的泛型集合并不是基类型的集合:List<Animal> animals = new List<Dog>(); 无法编译,因为这样一来这个列表就能接受 Cat 了。只读视图是协变的,所以 IEnumerable<Animal> animals = new List<Dog>(); 没有问题。泛型页面解释了其中的原因。
常见错误
- 忘记写
override。 方法能编译通过,只带一个警告,却悄悄地变成了隐藏而不是重写。把 CS0114 当作错误来对待。 - 把所有成员都设为
virtual。 每个虚成员都是对派生类的一种承诺:它何时被调用、可以假定什么。只标记你有意开放的扩展点。 - 继承层次过深。 三四层继承会让人很难判断到底执行的是哪个版本的方法。组合,也就是一个类持有另一个对象并调用它,往往比再加一层继承更简单。
- 在构造函数中调用虚方法。 重写的版本会在派生类构造函数体执行之前运行,所以那个构造函数体要赋值的字段此时仍然是默认值。
- 只为复用代码而使用继承。 如果
Stack继承自List,调用方就可以往你的栈中间Insert元素。应该改为在私有字段中持有一个List。
常见问题
C# 中的继承是怎么工作的?
一个类在冒号后面写出一个基类:class Dog : Animal。派生类会得到基类的所有成员(字段、属性、方法、事件),可以添加自己的成员,也可以重写基类标记为 virtual 的成员。构造函数不会被继承;private 成员虽然存在于对象中,但在派生类里无法访问。
C# 中 virtual 和 override 有什么区别?
virtual 写在基类方法上,表示“派生类可以替换它”。override 写在派生类方法上,负责完成替换。两者缺一不可:重写一个没有标记 virtual、abstract 或 override 的方法会报 CS0506 错误。调用虚方法时,运行时执行的是对象实际类型的版本,而不是变量类型的版本。
C# 中 new 和 override 有什么区别?
override 对所有调用方替换基类方法,即使代码是通过基类变量持有这个对象。new 只是隐藏它:把对象看作派生类型的代码会调用新方法,而把它看作基类型的代码仍然调用基类方法。所以 Base b = new Derived(); b.M(); 在用 override 时执行 Derived.M,用 new 时执行 Base.M。
C# 支持多重继承吗?
类不支持:一个类只能有一个基类,写两个会报 CS1721 错误。但一个类可以实现任意数量的接口,C# 就是用这种方式表达“这个类型能做好几件事”的。从 C# 8 开始,接口还可以带有默认的方法实现。
C# 中的 sealed 是什么意思?
sealed 类不能被用作基类;从它派生会报 CS0509 错误。例如 string 就是 sealed 的。用在方法上时,sealed override 会阻止继承层次中更下层的类再次重写它(CS0239)。对于不是为继承而设计的类,把它密封是一个合理的默认做法。
C# 中如何调用基类的方法?
在派生类中使用 base.MethodName(...),通常写在重写的方法里:public override string Describe() => base.Describe() + " with GPS";。对于构造函数,在参数列表后面写 : base(...),指定要运行哪个基类构造函数。